1#[derive(Debug)]
2pub struct RemotePath {
3 session: remote::SshSession,
4 path: std::path::PathBuf,
5}
6
7impl RemotePath {
8 pub fn new(session: remote::SshSession, path: std::path::PathBuf) -> anyhow::Result<Self> {
9 if !path.is_absolute() {
10 return Err(anyhow::anyhow!("Path must be absolute: {}", path.display()));
11 }
12 Ok(Self { session, path })
13 }
14
15 #[must_use]
16 pub fn from_local(path: &std::path::Path) -> Self {
17 Self {
18 session: remote::SshSession::local(),
19 path: path.to_path_buf(),
20 }
21 }
22
23 #[must_use]
24 pub fn session(&self) -> &remote::SshSession {
25 &self.session
26 }
27
28 #[must_use]
29 pub fn path(&self) -> &std::path::Path {
30 &self.path
31 }
32}
33
34#[derive(Debug)]
35pub enum PathType {
36 Local(std::path::PathBuf),
37 Remote(RemotePath),
38}
39
40impl PartialEq for PathType {
41 fn eq(&self, other: &Self) -> bool {
42 match (self, other) {
43 (PathType::Local(_), PathType::Local(_)) => true, (PathType::Local(_), PathType::Remote(_)) => false,
45 (PathType::Remote(_), PathType::Local(_)) => false,
46 (PathType::Remote(remote1), PathType::Remote(remote2)) => {
47 remote1.session() == remote2.session()
48 }
49 }
50 }
51}
52
53fn get_remote_path_regex() -> &'static regex::Regex {
55 use std::sync::OnceLock;
56 static REGEX: OnceLock<regex::Regex> = OnceLock::new();
57 REGEX.get_or_init(|| {
58 regex::Regex::new(
59 r"^(?:(?P<user>[^@]+)@)?(?P<host>(?:\[[^\]]+\]|[^:\[\]]+))(?::(?P<port>\d+))?:(?P<path>.+)$"
60 ).unwrap()
61 })
62}
63
64fn split_remote_path(path_str: &str) -> (Option<String>, &str) {
68 let re = get_remote_path_regex();
69 if let Some(captures) = re.captures(path_str) {
70 let path_part = captures.name("path").unwrap().as_str();
71 let path_start = path_str.len() - path_part.len();
73 let host_prefix = &path_str[..path_start];
74 (Some(host_prefix.to_string()), path_part)
75 } else {
76 (None, path_str)
77 }
78}
79
80fn extract_filesystem_path(path_str: &str) -> &str {
84 split_remote_path(path_str).1
85}
86
87fn join_path_with_filename(host_prefix: Option<String>, dir_path: &str, filename: &str) -> String {
91 let fs_path = std::path::Path::new(dir_path);
92 let joined = fs_path.join(filename);
93 let joined_str = joined.to_string_lossy();
94 if let Some(prefix) = host_prefix {
95 format!("{prefix}{joined_str}")
96 } else {
97 joined_str.to_string()
98 }
99}
100
101#[must_use]
102pub fn parse_path(path: &str) -> PathType {
103 let re = get_remote_path_regex();
104 if let Some(captures) = re.captures(path) {
105 let user = captures.name("user").map(|m| m.as_str().to_string());
107 let host = captures.name("host").unwrap().as_str().to_string();
108 let port = captures
109 .name("port")
110 .and_then(|m| m.as_str().parse::<u16>().ok());
111 let remote_path = captures
112 .name("path")
113 .expect("Unable to extract file system path from provided remote path")
114 .as_str();
115 let remote_path = if std::path::Path::new(remote_path).is_absolute() {
116 std::path::Path::new(remote_path).to_path_buf()
117 } else {
118 std::env::current_dir()
119 .unwrap_or_else(|_| std::path::PathBuf::from("/"))
120 .join(remote_path)
121 };
122 PathType::Remote(
123 RemotePath::new(remote::SshSession { user, host, port }, remote_path).unwrap(), )
125 } else {
126 PathType::Local(path.into())
128 }
129}
130
131pub fn validate_destination_path(dst_path_str: &str) -> anyhow::Result<()> {
140 let path_part = extract_filesystem_path(dst_path_str);
142 if path_part.ends_with("/.") {
144 return Err(anyhow::anyhow!(
145 "Destination path cannot end with '/.' (current directory).\n\
146 If you want to copy into the current directory, use './' instead.\n\
147 Example: 'rcp source.txt ./' copies source.txt into current directory as source.txt"
148 ));
149 } else if path_part.ends_with("/..") {
150 return Err(anyhow::anyhow!(
151 "Destination path cannot end with '/..' (parent directory).\n\
152 If you want to copy into the parent directory, use '../' instead.\n\
153 Example: 'rcp source.txt ../' copies source.txt into parent directory as source.txt"
154 ));
155 } else if path_part == "." {
156 return Err(anyhow::anyhow!(
157 "Destination path cannot be '.' (current directory).\n\
158 If you want to copy into the current directory, use './' instead.\n\
159 Example: 'rcp source.txt ./' copies source.txt into current directory as source.txt"
160 ));
161 } else if path_part == ".." {
162 return Err(anyhow::anyhow!(
163 "Destination path cannot be '..' (parent directory).\n\
164 If you want to copy into the parent directory, use '../' instead.\n\
165 Example: 'rcp source.txt ../' copies source.txt into parent directory as source.txt"
166 ));
167 }
168 Ok(())
169}
170
171pub fn resolve_destination_path(src_path_str: &str, dst_path_str: &str) -> anyhow::Result<String> {
184 validate_destination_path(dst_path_str)?;
186 if dst_path_str.ends_with('/') {
187 let actual_src_path = std::path::Path::new(extract_filesystem_path(src_path_str));
189 let src_file_name = actual_src_path.file_name().ok_or_else(|| {
190 anyhow::anyhow!("Source path {:?} does not have a basename", actual_src_path)
191 })?;
192 let (host_prefix, dir_path) = split_remote_path(dst_path_str);
194 let filename = src_file_name.to_string_lossy();
195 Ok(join_path_with_filename(host_prefix, dir_path, &filename))
196 } else {
197 Ok(dst_path_str.to_string())
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn test_parse_path_local() {
208 match parse_path("/path/to/file") {
209 PathType::Local(path) => assert_eq!(path.to_str().unwrap(), "/path/to/file"),
210 _ => panic!("Expected local path"),
211 }
212 }
213
214 #[test]
215 fn test_parse_path_remote_basic() {
216 match parse_path("host:/path/to/file") {
217 PathType::Remote(remote_path) => {
218 assert_eq!(remote_path.session().user, None);
219 assert_eq!(remote_path.session().host, "host");
220 assert_eq!(remote_path.session().port, None);
221 assert_eq!(remote_path.path().to_str().unwrap(), "/path/to/file");
222 }
223 _ => panic!("Expected remote path"),
224 }
225 }
226
227 #[test]
228 fn test_parse_path_remote_full() {
229 match parse_path("user@host:22:/path/to/file") {
230 PathType::Remote(remote_path) => {
231 assert_eq!(remote_path.session().user, Some("user".to_string()));
232 assert_eq!(remote_path.session().host, "host");
233 assert_eq!(remote_path.session().port, Some(22));
234 assert_eq!(remote_path.path().to_str().unwrap(), "/path/to/file");
235 }
236 _ => panic!("Expected remote path"),
237 }
238 }
239
240 #[test]
241 fn test_parse_path_ipv6() {
242 match parse_path("[2001:db8::1]:/path/to/file") {
243 PathType::Remote(remote_path) => {
244 assert_eq!(remote_path.session().user, None);
245 assert_eq!(remote_path.session().host, "[2001:db8::1]");
246 assert_eq!(remote_path.session().port, None);
247 assert_eq!(remote_path.path().to_str().unwrap(), "/path/to/file");
248 }
249 _ => panic!("Expected remote path"),
250 }
251 }
252
253 #[test]
254 fn test_resolve_destination_path_local_with_trailing_slash() {
255 let result = resolve_destination_path("/path/to/file.txt", "/dest/").unwrap();
256 assert_eq!(result, "/dest/file.txt");
257 }
258
259 #[test]
260 fn test_resolve_destination_path_local_without_trailing_slash() {
261 let result = resolve_destination_path("/path/to/file.txt", "/dest/newname.txt").unwrap();
262 assert_eq!(result, "/dest/newname.txt");
263 }
264
265 #[test]
266 fn test_resolve_destination_path_remote_with_trailing_slash() {
267 let result = resolve_destination_path("host:/path/to/file.txt", "/dest/").unwrap();
268 assert_eq!(result, "/dest/file.txt");
269 }
270
271 #[test]
272 fn test_resolve_destination_path_remote_without_trailing_slash() {
273 let result =
274 resolve_destination_path("host:/path/to/file.txt", "/dest/newname.txt").unwrap();
275 assert_eq!(result, "/dest/newname.txt");
276 }
277
278 #[test]
279 fn test_resolve_destination_path_remote_complex() {
280 let result =
281 resolve_destination_path("user@host:22:/home/user/docs/report.pdf", "host2:/backup/")
282 .unwrap();
283 assert_eq!(result, "host2:/backup/report.pdf");
284 }
285
286 #[test]
287 fn test_validate_destination_path_dot_local() {
288 let result = resolve_destination_path("/path/to/file.txt", "/dest/.");
289 assert!(result.is_err());
290 let error = result.unwrap_err();
291 assert!(error.to_string().contains("cannot end with '/.'"));
292 assert!(error.to_string().contains("use './' instead"));
293 }
294
295 #[test]
296 fn test_validate_destination_path_double_dot_local() {
297 let result = resolve_destination_path("/path/to/file.txt", "/dest/..");
298 assert!(result.is_err());
299 let error = result.unwrap_err();
300 assert!(error.to_string().contains("cannot end with '/..'"));
301 assert!(error.to_string().contains("use '../' instead"));
302 }
303
304 #[test]
305 fn test_validate_destination_path_dot_remote() {
306 let result = resolve_destination_path("host:/path/to/file.txt", "host2:/dest/.");
307 assert!(result.is_err());
308 let error = result.unwrap_err();
309 assert!(error.to_string().contains("cannot end with '/.'"));
310 }
311
312 #[test]
313 fn test_validate_destination_path_double_dot_remote() {
314 let result = resolve_destination_path("host:/path/to/file.txt", "host2:/dest/..");
315 assert!(result.is_err());
316 let error = result.unwrap_err();
317 assert!(error.to_string().contains("cannot end with '/..'"));
318 }
319
320 #[test]
321 fn test_validate_destination_path_bare_dot() {
322 let result = resolve_destination_path("/path/to/file.txt", ".");
323 assert!(result.is_err());
324 let error = result.unwrap_err();
325 assert!(error.to_string().contains("cannot be '.'"));
326 }
327
328 #[test]
329 fn test_validate_destination_path_bare_double_dot() {
330 let result = resolve_destination_path("/path/to/file.txt", "..");
331 assert!(result.is_err());
332 let error = result.unwrap_err();
333 assert!(error.to_string().contains("cannot be '..'"));
334 }
335
336 #[test]
337 fn test_validate_destination_path_remote_bare_dot() {
338 let result = resolve_destination_path("host:/path/to/file.txt", "host2:.");
339 assert!(result.is_err());
340 let error = result.unwrap_err();
341 assert!(error.to_string().contains("cannot be '.'"));
342 }
343
344 #[test]
345 fn test_validate_destination_path_remote_bare_double_dot() {
346 let result = resolve_destination_path("host:/path/to/file.txt", "host2:..");
347 assert!(result.is_err());
348 let error = result.unwrap_err();
349 assert!(error.to_string().contains("cannot be '..'"));
350 }
351
352 #[test]
353 fn test_validate_destination_path_dot_with_slash_allowed() {
354 let result = resolve_destination_path("/path/to/file.txt", "./").unwrap();
356 assert_eq!(result, "./file.txt");
357 let result = resolve_destination_path("/path/to/file.txt", "../").unwrap();
358 assert_eq!(result, "../file.txt");
359 }
360
361 #[test]
362 fn test_validate_destination_path_normal_paths_allowed() {
363 let result = resolve_destination_path("/path/to/file.txt", "/dest/normal").unwrap();
365 assert_eq!(result, "/dest/normal");
366 let result = resolve_destination_path("/path/to/file.txt", "/dest.txt").unwrap();
367 assert_eq!(result, "/dest.txt");
368 let result = resolve_destination_path("/path/to/file.txt", "/dest.backup/").unwrap();
370 assert_eq!(result, "/dest.backup/file.txt");
371 }
372
373 #[test]
374 fn test_resolve_destination_path_remote_with_complex_host() {
375 let result =
377 resolve_destination_path("host:/path/to/file.txt", "user@host2:22:/backup/").unwrap();
378 assert_eq!(result, "user@host2:22:/backup/file.txt");
379
380 let result =
381 resolve_destination_path("[::1]:/path/file.txt", "[2001:db8::1]:8080:/dest/").unwrap();
382 assert_eq!(result, "[2001:db8::1]:8080:/dest/file.txt");
383 }
384
385 #[test]
386 fn test_split_remote_path() {
387 assert_eq!(
389 split_remote_path("user@host:22:/path/file"),
390 (Some("user@host:22:".to_string()), "/path/file")
391 );
392 assert_eq!(
393 split_remote_path("host:/path/file"),
394 (Some("host:".to_string()), "/path/file")
395 );
396 assert_eq!(
397 split_remote_path("[::1]:8080:/path/file"),
398 (Some("[::1]:8080:".to_string()), "/path/file")
399 );
400
401 assert_eq!(
403 split_remote_path("/local/path/file"),
404 (None, "/local/path/file")
405 );
406 assert_eq!(
407 split_remote_path("relative/path/file"),
408 (None, "relative/path/file")
409 );
410 }
411
412 #[test]
413 fn test_extract_filesystem_path() {
414 assert_eq!(
416 extract_filesystem_path("user@host:22:/path/file"),
417 "/path/file"
418 );
419 assert_eq!(extract_filesystem_path("host:/path/file"), "/path/file");
420 assert_eq!(
421 extract_filesystem_path("[::1]:8080:/path/file"),
422 "/path/file"
423 );
424
425 assert_eq!(
427 extract_filesystem_path("/local/path/file"),
428 "/local/path/file"
429 );
430 assert_eq!(
431 extract_filesystem_path("relative/path/file"),
432 "relative/path/file"
433 );
434 }
435
436 #[test]
437 fn test_join_path_with_filename() {
438 assert_eq!(
440 join_path_with_filename(Some("user@host:22:".to_string()), "/backup/", "file.txt"),
441 "user@host:22:/backup/file.txt"
442 );
443 assert_eq!(
444 join_path_with_filename(Some("[::1]:8080:".to_string()), "/dest/", "file.txt"),
445 "[::1]:8080:/dest/file.txt"
446 );
447
448 assert_eq!(
450 join_path_with_filename(None, "/backup/", "file.txt"),
451 "/backup/file.txt"
452 );
453 assert_eq!(
454 join_path_with_filename(None, "relative/", "file.txt"),
455 "relative/file.txt"
456 );
457 }
458
459 #[test]
460 fn test_ipv6_edge_cases_consistency() {
461 let test_cases = [
463 "[::1]:/path/file",
464 "[2001:db8::1]:/path/file",
465 "[2001:db8::1]:8080:/path/file",
466 "user@[::1]:/path/file",
467 "user@[2001:db8::1]:22:/path/file",
468 ];
469
470 for case in test_cases {
471 let (prefix, _path_part) = split_remote_path(case);
473 assert!(prefix.is_some(), "Should detect {case} as remote");
474
475 let fs_path = extract_filesystem_path(case);
477 assert_eq!(
478 fs_path, "/path/file",
479 "Should extract filesystem path from {case}"
480 );
481
482 match parse_path(case) {
484 PathType::Remote(remote) => {
485 assert_eq!(
486 remote.path().to_str().unwrap(),
487 "/path/file",
488 "parse_path should extract same filesystem path from {case}"
489 );
490 }
491 PathType::Local(_) => panic!("parse_path should detect {case} as remote"),
492 }
493
494 if let (Some(host_prefix), dir_path) = split_remote_path(&case.replace("file", "")) {
496 let reconstructed = join_path_with_filename(Some(host_prefix), dir_path, "file");
497 assert_eq!(
498 reconstructed, case,
499 "Should be able to reconstruct {case} correctly"
500 );
501 }
502 }
503 }
504}