rcp_tools_rcp/
path.rs

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, // Local paths are always equal
44            (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
53/// Gets the compiled regex for parsing remote paths (shared with `parse_path`)
54fn 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
64/// Splits a remote path string into (`host_prefix`, `path_part`) using the same logic as `parse_path`
65/// For example: "user@host:22:/path/to/file" -> ("user@host:22:", "/path/to/file")
66/// For local paths, returns (None, `original_path`)
67fn 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        // Reconstruct the host prefix part by finding where the path starts
72        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
80/// Extracts just the filesystem path part from a remote or local path string
81/// For example: "user@host:22:/path/to/file" -> "/path/to/file"
82/// For local paths, returns the original path
83fn extract_filesystem_path(path_str: &str) -> &str {
84    split_remote_path(path_str).1
85}
86
87/// Joins a filesystem path with a filename, handling both remote and local cases
88/// For example: ("user@host:22:", "/path/", "file.txt") -> "user@host:22:/path/file.txt"
89/// For local: (None, "/path/", "file.txt") -> "/path/file.txt"
90fn 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        // It's a remote path
106        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(), // parse_path assumes valid paths for now
124        )
125    } else {
126        // It's a local path
127        PathType::Local(path.into())
128    }
129}
130
131/// Validates that destination path doesn't end with problematic patterns like . or ..
132///
133/// # Arguments
134/// * `dst_path_str` - Destination path string to validate
135///
136/// # Returns
137/// * `Ok(())` - If path is valid
138/// * `Err(...)` - If path ends with . or .. with clear error message
139pub fn validate_destination_path(dst_path_str: &str) -> anyhow::Result<()> {
140    // Extract the filesystem path part for validation
141    let path_part = extract_filesystem_path(dst_path_str);
142    // Check the raw string for problematic endings, since Path::file_name() normalizes
143    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
171/// Resolves destination path handling trailing slash semantics for both local and remote paths.
172///
173/// This function implements the logic: "foo/bar -> baz/" becomes "foo/bar -> baz/bar"
174/// i.e., when destination ends with '/', copy source INTO the directory.
175///
176/// # Arguments
177/// * `src_path_str` - Source path as string (before parsing)
178/// * `dst_path_str` - Destination path as string (before parsing)
179///
180/// # Returns
181/// * `Ok(resolved_dst_path)` - Destination path with trailing slash logic applied
182/// * `Err(...)` - If path resolution fails or invalid combination detected
183pub fn resolve_destination_path(src_path_str: &str, dst_path_str: &str) -> anyhow::Result<String> {
184    // validate destination path doesn't end with problematic patterns
185    validate_destination_path(dst_path_str)?;
186    if dst_path_str.ends_with('/') {
187        // extract source file name to append to destination directory
188        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        // construct destination: "baz/" + "bar" -> "baz/bar"
193        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        // no trailing slash - use destination as-is
198        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        // these should work fine because they end with '/' not '.'
355        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        // normal paths should work fine
364        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        // paths containing dots but not ending with them should work
369        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        // test with complex remote paths including ports and IPv6
376        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        // test remote path splitting
388        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        // test local path
402        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        // test remote paths
415        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        // test local paths
426        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        // test remote path joining
439        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        // test local path joining
449        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        // Test that helper functions and parse_path handle IPv6 consistently
462        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            // Test that split_remote_path works correctly
472            let (prefix, _path_part) = split_remote_path(case);
473            assert!(prefix.is_some(), "Should detect {case} as remote");
474
475            // Test that extract_filesystem_path works correctly
476            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            // Test that parse_path can parse the same string
483            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            // Test that join_path_with_filename can reconstruct correctly
495            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}