1use serde_json::{Number, Value};
5
6use crate::error::{Result, UnifiError};
7
8pub fn substitute_path(
17 template: &str,
18 params: &Value,
19 allowed_wildcard_prefixes: &[&str],
20) -> Result<String> {
21 let mut path = template.to_string();
22 while let Some(start) = path.find('{') {
23 let Some(end_offset) = path[start..].find('}') else {
24 return Err(UnifiError::PathTemplate(
25 "path template contains an unmatched opening brace".to_string(),
26 ));
27 };
28 let end = start + end_offset;
29 let key = &path[start + 1..end];
30 if key.is_empty() {
31 return Err(UnifiError::PathTemplate(
32 "path template contains an empty {} parameter".to_string(),
33 ));
34 }
35 let value = path_scalar(params, key)?;
36 path.replace_range(start..=end, &encode_path_segment(&value));
37 }
38
39 if path.contains("*path") {
40 let Some(value) = params.get("path").and_then(Value::as_str) else {
41 return Err(UnifiError::PathTemplate(
42 "missing required path parameter: path".to_string(),
43 ));
44 };
45 validate_connector_path(value, allowed_wildcard_prefixes)?;
46 path = path.replace("*path", value.trim_start_matches('/'));
47 }
48
49 Ok(path)
50}
51
52pub fn encode_path_segment(value: &str) -> String {
55 let mut encoded = String::with_capacity(value.len());
56 for byte in value.bytes() {
57 match byte {
58 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
59 encoded.push(byte as char);
60 }
61 _ => encoded.push_str(&format!("%{byte:02X}")),
62 }
63 }
64 encoded
65}
66
67pub fn validate_connector_path(path: &str, allowed_prefixes: &[&str]) -> Result<()> {
78 if is_unsafe_connector_path(path) {
79 return Err(UnifiError::ConnectorPath(path.to_string()));
80 }
81 if allowed_prefixes
82 .iter()
83 .any(|prefix| path.starts_with(prefix))
84 {
85 Ok(())
86 } else {
87 Err(UnifiError::ConnectorPath(format!(
88 "{path} is outside the supported integration API prefix"
89 )))
90 }
91}
92
93fn path_scalar(params: &Value, key: &str) -> Result<String> {
94 match params.get(key) {
95 Some(Value::String(value)) => Ok(value.clone()),
96 Some(Value::Number(value)) => Ok(number_to_string(value)),
97 Some(Value::Bool(value)) => Ok(value.to_string()),
98 Some(_) => Err(UnifiError::PathTemplate(format!(
99 "path parameter {key} must be a string, number, or boolean"
100 ))),
101 None => Err(UnifiError::PathTemplate(format!(
102 "missing required path parameter: {key}"
103 ))),
104 }
105}
106
107fn number_to_string(value: &Number) -> String {
108 value
109 .as_i64()
110 .map(|v| v.to_string())
111 .or_else(|| value.as_u64().map(|v| v.to_string()))
112 .or_else(|| value.as_f64().map(|v| v.to_string()))
113 .unwrap_or_else(|| value.to_string())
114}
115
116fn is_unsafe_connector_path(path: &str) -> bool {
117 if path.is_empty()
118 || !path.starts_with('/')
119 || path.starts_with("//")
120 || path.contains("://")
121 || path.contains('\\')
122 || path.contains('?')
123 || path.contains('#')
124 || path.contains("..")
125 {
126 return true;
127 }
128
129 let lowercase = path.to_ascii_lowercase();
130 lowercase.contains("%2e")
131 || lowercase.contains("%2f")
132 || lowercase.contains("%5c")
133 || lowercase.contains("%00")
134}
135
136#[cfg(test)]
137mod tests {
138 use serde_json::json;
139
140 use super::*;
141
142 #[test]
143 fn substitute_path_replaces_string_number_and_bool_params() {
144 let params = json!({ "id": "abc", "count": 3, "active": true });
145
146 let path = substitute_path("/sites/{id}/{count}/{active}", ¶ms, &[]).unwrap();
147
148 assert_eq!(path, "/sites/abc/3/true");
149 }
150
151 #[test]
152 fn substitute_path_percent_encodes_replaced_segments() {
153 let params = json!({ "id": "a b/c" });
154
155 let path = substitute_path("/sites/{id}", ¶ms, &[]).unwrap();
156
157 assert_eq!(path, "/sites/a%20b%2Fc");
158 }
159
160 #[test]
161 fn substitute_path_errors_on_missing_param() {
162 let err = substitute_path("/sites/{id}", &json!({}), &[]).unwrap_err();
163
164 assert!(
165 matches!(err, UnifiError::PathTemplate(msg) if msg.contains("missing required path parameter: id"))
166 );
167 }
168
169 #[test]
170 fn substitute_path_errors_on_unmatched_brace() {
171 let err = substitute_path("/sites/{id", &json!({}), &[]).unwrap_err();
172
173 assert!(matches!(err, UnifiError::PathTemplate(_)));
174 }
175
176 #[test]
177 fn substitute_path_expands_an_allowed_wildcard() {
178 let params = json!({ "path": "/proxy/network/integration/v1/sites" });
179
180 let path =
181 substitute_path("/proxy/*path", ¶ms, &["/proxy/network/integration/"]).unwrap();
182
183 assert_eq!(path, "/proxy/proxy/network/integration/v1/sites");
184 }
185
186 #[test]
187 fn substitute_path_rejects_a_wildcard_outside_the_allowed_prefix() {
188 let params = json!({ "path": "/etc/passwd" });
189
190 let err =
191 substitute_path("/proxy/*path", ¶ms, &["/proxy/network/integration/"]).unwrap_err();
192
193 assert!(matches!(err, UnifiError::ConnectorPath(_)));
194 }
195
196 #[test]
197 fn validate_connector_path_rejects_traversal() {
198 assert!(validate_connector_path(
199 "/proxy/network/integration/../secret",
200 &["/proxy/network/integration/"]
201 )
202 .is_err());
203 }
204
205 #[test]
206 fn validate_connector_path_rejects_encoded_separators() {
207 assert!(validate_connector_path(
208 "/proxy/network/integration/%2e%2e",
209 &["/proxy/network/integration/"]
210 )
211 .is_err());
212 }
213
214 #[test]
215 fn validate_connector_path_accepts_an_allowed_path() {
216 assert!(validate_connector_path(
217 "/proxy/network/integration/v1/sites",
218 &["/proxy/network/integration/"]
219 )
220 .is_ok());
221 }
222
223 #[test]
224 fn encode_path_segment_leaves_unreserved_characters_untouched() {
225 assert_eq!(encode_path_segment("abc-123_ABC.~"), "abc-123_ABC.~");
226 }
227
228 #[test]
229 fn encode_path_segment_percent_encodes_everything_else() {
230 assert_eq!(encode_path_segment("a b"), "a%20b");
231 }
232}