Skip to main content

data_gov/
util.rs

1/// Sanitize a string for use as a single filesystem path component.
2///
3/// Removes path traversal sequences (`..`, `/`, `\`) and filters to
4/// alphanumeric characters plus `-`, `_`, and `.`.
5// Three distinct patterns (".." then two separators); collapsing into a
6// single `replace` would change behavior since `..` must be handled first.
7#[allow(clippy::collapsible_str_replace)]
8pub fn sanitize_path_component(s: &str) -> String {
9    s.replace("..", "_")
10        .replace('/', "_")
11        .replace('\\', "_")
12        .chars()
13        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
14        .collect()
15}
16
17#[cfg(test)]
18mod tests {
19    use super::*;
20
21    #[test]
22    fn test_sanitize_removes_path_traversal() {
23        assert_eq!(
24            sanitize_path_component("../../etc/passwd"),
25            "____etc_passwd"
26        );
27    }
28
29    #[test]
30    fn test_sanitize_removes_backslash() {
31        assert_eq!(sanitize_path_component("foo\\bar"), "foo_bar");
32    }
33
34    #[test]
35    fn test_sanitize_preserves_safe_chars() {
36        assert_eq!(
37            sanitize_path_component("my-dataset_2024.csv"),
38            "my-dataset_2024.csv"
39        );
40    }
41
42    #[test]
43    fn test_sanitize_strips_special_chars() {
44        assert_eq!(sanitize_path_component("hello world!@#"), "helloworld");
45    }
46
47    #[test]
48    fn test_sanitize_empty_string() {
49        assert_eq!(sanitize_path_component(""), "");
50    }
51
52    #[test]
53    fn test_sanitize_single_dot_preserved() {
54        assert_eq!(sanitize_path_component("."), ".");
55    }
56
57    #[test]
58    fn test_sanitize_hidden_file_prefix_preserved() {
59        assert_eq!(sanitize_path_component(".bashrc"), ".bashrc");
60    }
61
62    #[test]
63    fn test_sanitize_trailing_dot_preserved() {
64        assert_eq!(sanitize_path_component("file."), "file.");
65    }
66
67    #[test]
68    fn test_sanitize_three_dots_replaces_leading_pair() {
69        assert_eq!(sanitize_path_component("..."), "_.");
70    }
71
72    #[test]
73    fn test_sanitize_four_dots_replaces_both_pairs() {
74        assert_eq!(sanitize_path_component("...."), "__");
75    }
76
77    #[test]
78    fn test_sanitize_embedded_parent_traversal_replaced() {
79        assert_eq!(sanitize_path_component("foo..bar"), "foo_bar");
80    }
81
82    #[test]
83    fn test_sanitize_preserves_unicode_letters() {
84        assert_eq!(sanitize_path_component("résumé"), "résumé");
85        assert_eq!(sanitize_path_component("日本語"), "日本語");
86    }
87
88    #[test]
89    fn test_sanitize_only_special_chars_returns_empty() {
90        assert_eq!(sanitize_path_component("!@#$%^&*()"), "");
91    }
92
93    #[test]
94    fn test_sanitize_long_input_does_not_panic() {
95        let long = "a".repeat(10_000);
96        let result = sanitize_path_component(&long);
97        assert_eq!(result.len(), 10_000);
98    }
99
100    #[test]
101    fn test_sanitize_mixed_safe_and_traversal() {
102        assert_eq!(
103            sanitize_path_component("safe-name../evil"),
104            "safe-name__evil"
105        );
106    }
107}