kittycad_modeling_cmds/shared/
safe_filepath.rs1use schemars::JsonSchema;
3use serde_with::{DeserializeFromStr, SerializeDisplay};
4use typed_path::{TypedPath, UnixComponent, WindowsComponent};
5
6#[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#[derive(Debug)]
15pub enum PathNotSafe {
16 CannotBeAbsolute,
18 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 pub fn validate(unparsed_user_path: &str) -> Result<Self, PathNotSafe> {
36 let user_path = TypedPath::derive(unparsed_user_path);
37
38 if user_path.is_absolute() {
40 return Err(PathNotSafe::CannotBeAbsolute);
41 }
42
43 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 Ok(Self(format!("{}", user_path.display())))
69 }
70}
71
72impl 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 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 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}