Skip to main content

mimick/
sanitize.rs

1//! Input sanitisation for file paths and URLs.
2//!
3//! Guards against:
4//! * Directory traversal in filenames originating from Immich API responses
5//! * Non-HTTP(S) URL schemes in user-provided server addresses
6
7use std::path::Path;
8
9/// Strip a filename to its final component and reject traversal attempts.
10///
11/// Returns `None` if the input is empty, contains only path separators, or
12/// resolves to `.` / `..` after extraction.
13///
14/// # Examples
15/// ```ignore
16/// assert_eq!(safe_filename("photo.jpg"), Some("photo.jpg".into()));
17/// assert_eq!(safe_filename("../../../etc/passwd"), Some("passwd".into()));
18/// assert_eq!(safe_filename("sub/dir/file.jpg"), Some("file.jpg".into()));
19/// ```
20pub fn safe_filename(name: &str) -> Option<String> {
21    let name = name.trim();
22    if name.is_empty() {
23        return None;
24    }
25
26    let p = Path::new(name);
27    let stem = p.file_name()?;
28    let s = stem.to_string_lossy();
29
30    // Reject hidden traversal through the file_name component itself.
31    if s == "." || s == ".." || s.is_empty() {
32        return None;
33    }
34
35    Some(s.into_owned())
36}
37
38/// Validate that a URL string uses an HTTP or HTTPS scheme.
39///
40/// Returns the parsed, normalised URL on success, or a human-readable error
41/// string suitable for display in the settings UI.
42pub fn validate_http_url(raw: &str) -> Result<url::Url, String> {
43    let trimmed = raw.trim();
44    if trimmed.is_empty() {
45        return Err("URL is empty".into());
46    }
47
48    let parsed = url::Url::parse(trimmed).map_err(|e| format!("Invalid URL: {}", e))?;
49
50    match parsed.scheme() {
51        "http" | "https" => Ok(parsed),
52        other => Err(format!(
53            "Unsupported URL scheme '{}'. Only http:// and https:// are allowed.",
54            other
55        )),
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    // -- safe_filename --
64
65    #[test]
66    fn safe_filename_extracts_basename() {
67        assert_eq!(safe_filename("photo.jpg"), Some("photo.jpg".into()));
68    }
69
70    #[test]
71    fn safe_filename_strips_directory_components() {
72        assert_eq!(safe_filename("sub/dir/file.jpg"), Some("file.jpg".into()));
73    }
74
75    #[test]
76    fn safe_filename_strips_traversal_to_basename() {
77        // The defense is that traversal components are stripped, leaving only
78        // the final filename component.
79        assert_eq!(safe_filename("../../../etc/passwd"), Some("passwd".into()));
80    }
81
82    #[test]
83    fn safe_filename_rejects_empty() {
84        assert_eq!(safe_filename(""), None);
85        assert_eq!(safe_filename("   "), None);
86    }
87
88    #[test]
89    fn safe_filename_rejects_dot_dot() {
90        assert_eq!(safe_filename(".."), None);
91        assert_eq!(safe_filename("."), None);
92    }
93
94    // -- validate_http_url --
95
96    #[test]
97    fn validate_accepts_https() {
98        let result = validate_http_url("https://immich.example.com");
99        assert!(result.is_ok());
100        assert_eq!(result.unwrap().scheme(), "https");
101    }
102
103    #[test]
104    fn validate_accepts_http() {
105        let result = validate_http_url("http://192.168.1.1:2283");
106        assert!(result.is_ok());
107    }
108
109    #[test]
110    fn validate_rejects_file_scheme() {
111        let result = validate_http_url("file:///etc/passwd");
112        assert!(result.is_err());
113        assert!(result.unwrap_err().contains("file"));
114    }
115
116    #[test]
117    fn validate_rejects_javascript_scheme() {
118        let result = validate_http_url("javascript:alert(1)");
119        assert!(result.is_err());
120    }
121
122    #[test]
123    fn validate_rejects_empty() {
124        assert!(validate_http_url("").is_err());
125        assert!(validate_http_url("  ").is_err());
126    }
127
128    #[test]
129    fn validate_trims_whitespace() {
130        let result = validate_http_url("  https://immich.local  ");
131        assert!(result.is_ok());
132    }
133}