Skip to main content

kittycad_modeling_cmds/shared/
safe_filepath.rs

1//! Filepaths safe to use in KCL projects because they cannot escape the KCL project root.
2use schemars::JsonSchema;
3use serde_with::{DeserializeFromStr, SerializeDisplay};
4use typed_path::{TypedPath, UnixComponent, WindowsComponent};
5
6/// Filepath which is guaranteed to be relative and not contain parent directory jumps like '..'
7#[derive(Debug, Clone, PartialEq, Eq, SerializeDisplay, DeserializeFromStr, JsonSchema, Default)]
8#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
9#[cfg_attr(feature = "ts-rs", ts(export_to = "ModelingCmd.ts"))]
10#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
11pub struct SafeFilepath(String);
12
13/// Validation error that can occur when trying to send a file to the Zoo API.
14#[derive(Debug)]
15pub enum PathNotSafe {
16    /// Cannot use an absolute path.
17    CannotBeAbsolute,
18    /// Cannot use a parent path component (..)
19    CannotUseParent,
20}
21
22impl std::fmt::Display for PathNotSafe {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        let msg = match self {
25            PathNotSafe::CannotBeAbsolute => "you cannot use an absolute path here",
26            PathNotSafe::CannotUseParent => "you cannot use a parent jump like '..' here",
27        };
28        write!(f, "{}", msg)
29    }
30}
31
32impl SafeFilepath {
33    /// Validate if a path meets the invariants of this type,
34    /// i.e. is relative and doesn't contain any parent components (..)
35    pub fn validate(unparsed_user_path: &str) -> Result<Self, PathNotSafe> {
36        let user_path = TypedPath::derive(unparsed_user_path);
37
38        // Cannot be absolute.
39        if user_path.is_absolute() {
40            return Err(PathNotSafe::CannotBeAbsolute);
41        }
42
43        // Check all components to make sure there's no absolute or escaping from the project root.
44        match user_path {
45            TypedPath::Unix(path) => {
46                for component in path.components() {
47                    match component {
48                        UnixComponent::RootDir => return Err(PathNotSafe::CannotBeAbsolute),
49                        UnixComponent::ParentDir => return Err(PathNotSafe::CannotUseParent),
50                        UnixComponent::CurDir | UnixComponent::Normal(..) => {}
51                    }
52                }
53            }
54            TypedPath::Windows(path) => {
55                for component in path.components() {
56                    match component {
57                        WindowsComponent::Prefix(..) | WindowsComponent::RootDir => {
58                            return Err(PathNotSafe::CannotBeAbsolute)
59                        }
60                        WindowsComponent::ParentDir => return Err(PathNotSafe::CannotUseParent),
61                        WindowsComponent::CurDir | WindowsComponent::Normal(..) => {}
62                    }
63                }
64            }
65        }
66
67        // All checks passed, so it's OK.
68        Ok(Self(format!("{}", user_path.display())))
69    }
70}
71
72/// Parsing a SafeFilepath applies the validation checks.
73impl std::str::FromStr for SafeFilepath {
74    type Err = PathNotSafe;
75
76    fn from_str(unparsed_user_path: &str) -> Result<Self, Self::Err> {
77        SafeFilepath::validate(unparsed_user_path)
78    }
79}
80
81impl std::fmt::Display for SafeFilepath {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        self.0.fmt(f)
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use std::assert_matches;
90
91    use serde::{Deserialize, Serialize};
92
93    use super::*;
94
95    #[derive(Debug, Deserialize, Serialize, Eq, PartialEq)]
96    struct HasPath {
97        path: SafeFilepath,
98    }
99
100    #[test]
101    fn test_absolute_not_allowed_unix() {
102        // Absolute paths could allow reading from arbitrary files
103        let input = "/foo/bar";
104        let actual = SafeFilepath::validate(input);
105        assert_matches!(actual, Err(PathNotSafe::CannotBeAbsolute));
106    }
107
108    #[test]
109    fn test_absolute_not_allowed_windows() {
110        // Test windows-style absolute paths.
111        let input = r"C:\programs\bar";
112        let actual = SafeFilepath::validate(input);
113        assert_matches!(actual, Err(PathNotSafe::CannotBeAbsolute));
114    }
115
116    #[test]
117    fn test_parent_not_allowed() {
118        let input = "../../passwords/secret.txt";
119        let actual = SafeFilepath::validate(input);
120        assert_matches!(actual, Err(PathNotSafe::CannotUseParent));
121    }
122
123    #[test]
124    fn test_success() {
125        let input = "main.kcl";
126        let actual = SafeFilepath::validate(input);
127        assert!(actual.is_ok());
128    }
129
130    #[test]
131    fn test_success_nested_file() {
132        let input = "assets/bolt.step";
133        let actual = SafeFilepath::validate(input);
134        assert!(actual.is_ok());
135    }
136
137    #[test]
138    fn test_unsafe_path_rejected_during_deserialization() {
139        let input = r#"{
140            "path": "../password.txt"
141        }"#;
142        let deserialized: Result<HasPath, _> = serde_json::from_str(input);
143        assert!(deserialized.is_err());
144    }
145
146    #[test]
147    fn test_unsafe_path_rejected_during_parsing() {
148        let input = "../password.txt";
149        assert!(input.parse::<SafeFilepath>().is_err())
150    }
151
152    #[test]
153    fn test_safe_path_deserializes() {
154        let input = r#"{
155            "path": "file.txt"
156        }"#;
157        let deserialized: HasPath = serde_json::from_str(input).unwrap();
158        assert_eq!(
159            deserialized,
160            HasPath {
161                path: SafeFilepath::validate("file.txt").unwrap()
162            }
163        );
164    }
165}