1use colored::Colorize;
2use compact_str::ToCompactString;
3use garde::Validate;
4
5pub fn handle_startup_error<T>(err: anyhow::Error) -> T {
6 eprintln!("{}: {err:#?}", "an error occurred during startup".red());
7 std::process::exit(1);
8}
9
10#[inline]
11pub fn slice_up_to(s: &str, max_len: usize) -> &str {
12 if max_len >= s.len() || s.is_empty() {
13 return s;
14 }
15
16 let mut idx = max_len;
17 while !s.is_char_boundary(idx) {
18 idx -= 1;
19 }
20
21 &s[..idx]
22}
23
24#[inline]
25pub fn truncate_up_to(mut s: String, max_len: usize) -> String {
26 if max_len >= s.len() || s.is_empty() {
27 return s;
28 }
29
30 let mut idx = max_len;
31 while !s.is_char_boundary(idx) {
32 idx -= 1;
33 }
34
35 s.truncate(idx);
36 s
37}
38
39const SENSITIVE_QUERY_KEY_PARTS: [&str; 6] = [
40 "token",
41 "secret",
42 "password",
43 "key",
44 "signature",
45 "credential",
46];
47const SENSITIVE_QUERY_KEYS: [&str; 2] = ["code", "data"];
48
49fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
50 haystack
51 .as_bytes()
52 .windows(needle.len())
53 .any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
54}
55
56fn is_sensitive_query_key(key: &str) -> bool {
57 SENSITIVE_QUERY_KEYS
58 .iter()
59 .any(|sensitive| key.eq_ignore_ascii_case(sensitive))
60 || SENSITIVE_QUERY_KEY_PARTS
61 .iter()
62 .any(|part| contains_ignore_ascii_case(key, part))
63}
64
65pub fn redact_query(query: &str) -> std::borrow::Cow<'_, str> {
66 fn is_sensitive_pair(pair: &str) -> bool {
67 pair.split_once('=')
68 .is_some_and(|(key, _)| is_sensitive_query_key(key))
69 }
70
71 if !query.split('&').any(is_sensitive_pair) {
72 return std::borrow::Cow::Borrowed(query);
73 }
74
75 let mut redacted = String::with_capacity(query.len());
76 for (index, pair) in query.split('&').enumerate() {
77 if index > 0 {
78 redacted.push('&');
79 }
80
81 match pair.split_once('=') {
82 Some((key, _)) if is_sensitive_query_key(key) => {
83 redacted.push_str(key);
84 redacted.push_str("=<redacted>");
85 }
86 _ => redacted.push_str(pair),
87 }
88 }
89
90 std::borrow::Cow::Owned(redacted)
91}
92
93pub fn redact_url(url: &str) -> std::borrow::Cow<'_, str> {
94 let Some((base, query)) = url.split_once('?') else {
95 return std::borrow::Cow::Borrowed(url);
96 };
97
98 match redact_query(query) {
99 std::borrow::Cow::Borrowed(_) => std::borrow::Cow::Borrowed(url),
100 std::borrow::Cow::Owned(redacted) => std::borrow::Cow::Owned(format!("{base}?{redacted}")),
101 }
102}
103
104pub fn validate_language(
105 language: &compact_str::CompactString,
106 _context: &(),
107) -> Result<(), garde::Error> {
108 if !crate::FRONTEND_LANGUAGES.contains(language) {
109 return Err(garde::Error::new(compact_str::format_compact!(
110 "invalid language: {language}"
111 )));
112 }
113
114 Ok(())
115}
116
117pub fn validate_host(host: &compact_str::CompactString, _context: &()) -> Result<(), garde::Error> {
118 if host.parse::<std::net::IpAddr>().is_ok() {
119 return Ok(());
120 }
121
122 let is_valid_hostname = host.len() <= 253
123 && host.split('.').all(|label| {
124 !label.is_empty()
125 && label.len() <= 63
126 && !label.starts_with('-')
127 && !label.ends_with('-')
128 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
129 });
130
131 if !is_valid_hostname {
132 return Err(garde::Error::new("must be a valid IP address or hostname"));
133 }
134
135 Ok(())
136}
137
138pub fn validate_http_url(url: &reqwest::Url, _context: &()) -> Result<(), garde::Error> {
139 if !matches!(url.scheme(), "http" | "https") {
140 return Err(garde::Error::new("must be a http or https url"));
141 }
142
143 Ok(())
144}
145
146pub fn validate_json_path(path: &str, _context: &()) -> Result<(), garde::Error> {
147 if let Err(err) = serde_json_path::JsonPath::parse(path) {
148 return Err(garde::Error::new(compact_str::format_compact!(
149 "must be a valid json path: {err}"
150 )));
151 }
152
153 Ok(())
154}
155
156pub fn validate_avatar_url_template(template: &str, _context: &()) -> Result<(), garde::Error> {
157 let mut rest = template;
158
159 while let Some(start) = rest.find('{') {
160 let Some(end) = rest[start..].find('}').map(|end| start + end) else {
161 return Err(garde::Error::new("has an unterminated { placeholder"));
162 };
163
164 validate_json_path(&rest[start + 1..end], &())?;
165
166 rest = &rest[end + 1..];
167 }
168
169 Ok(())
170}
171
172pub fn validate_ignored_files(
173 patterns: &[compact_str::CompactString],
174 _context: &(),
175) -> Result<(), garde::Error> {
176 let mut builder = crate::ignore_list::IgnoreList::builder();
177
178 for pattern in patterns {
179 if let Err(err) = builder.try_push_line(pattern) {
180 return Err(garde::Error::new(compact_str::format_compact!(
181 "{pattern} is not a valid pattern: {err}"
182 )));
183 }
184 }
185
186 if let Err(err) = builder.build() {
187 return Err(garde::Error::new(compact_str::format_compact!(
188 "patterns cannot be compiled: {err}"
189 )));
190 }
191
192 Ok(())
193}
194
195pub fn validate_time_in_future(
196 time: &chrono::DateTime<chrono::Utc>,
197 _context: &(),
198) -> Result<(), garde::Error> {
199 let now = chrono::Utc::now();
200 if *time <= now {
201 return Err(garde::Error::new("time must be in the future"));
202 }
203
204 Ok(())
205}
206
207#[inline]
208pub fn validate_data<T: Validate>(data: &T) -> Result<(), Vec<String>>
209where
210 T::Context: Default,
211{
212 if let Err(err) = data.validate() {
213 let error_messages = flatten_validation_errors(&err);
214
215 return Err(error_messages);
216 }
217
218 Ok(())
219}
220
221pub fn flatten_validation_errors(errors: &garde::Report) -> Vec<String> {
222 let mut messages = Vec::new();
223
224 for (path, error) in errors.iter() {
225 let full_name = path.to_compact_string();
226
227 messages.push(format!("{full_name}: {}", error.message()));
228 }
229
230 messages
231}
232
233pub fn axum_to_tungstenite(
234 msg: axum::extract::ws::Message,
235) -> tokio_tungstenite::tungstenite::Message {
236 use axum::extract::ws::Message;
237 use tokio_tungstenite::tungstenite::{Message as Tung, protocol::CloseFrame as TungClose};
238
239 match msg {
240 Message::Text(text) => Tung::Text(text.as_str().into()),
241 Message::Binary(data) => Tung::Binary(data),
242 Message::Ping(data) => Tung::Ping(data),
243 Message::Pong(data) => Tung::Pong(data),
244 Message::Close(frame) => Tung::Close(frame.map(|f| TungClose {
245 code: f.code.into(),
246 reason: f.reason.as_str().into(),
247 })),
248 }
249}
250
251pub fn tungstenite_to_axum(
252 msg: tokio_tungstenite::tungstenite::Message,
253) -> Option<axum::extract::ws::Message> {
254 use axum::extract::ws::{CloseFrame, Message};
255 use tokio_tungstenite::tungstenite::Message as Tung;
256
257 Some(match msg {
258 Tung::Text(text) => Message::Text(text.as_str().into()),
259 Tung::Binary(data) => Message::Binary(data),
260 Tung::Ping(data) => Message::Ping(data),
261 Tung::Pong(data) => Message::Pong(data),
262 Tung::Close(frame) => Message::Close(frame.map(|f| CloseFrame {
263 code: f.code.into(),
264 reason: f.reason.as_str().into(),
265 })),
266 Tung::Frame(_) => return None,
267 })
268}
269
270pub fn push_scope_or_star<'a>(
271 permissions: &mut Vec<&'a str>,
272 scope: Option<&'a [compact_str::CompactString]>,
273) {
274 match scope {
275 Some(scope) => permissions.extend(scope.iter().map(compact_str::CompactString::as_str)),
276 None => permissions.push("*"),
277 }
278}
279
280pub fn is_single_component_file_name(name: &str) -> bool {
281 let mut components = std::path::Path::new(name).components();
282
283 match (components.next(), components.next()) {
284 (Some(std::path::Component::Normal(component)), None) => component.to_str() == Some(name),
285 _ => false,
286 }
287}
288
289pub fn yaml_deserialize_options() -> serde_saphyr::Options {
292 serde_saphyr::options! {
293 alias_limits: serde_saphyr::alias_limits! {
294 max_alias_expansions_per_anchor: 0,
295 },
296 }
297}
298
299#[cfg(target_os = "linux")]
300pub fn process_memory_usage() -> Option<u64> {
301 let status = std::fs::read_to_string("/proc/self/status").ok()?;
302
303 status
304 .lines()
305 .find_map(|line| line.strip_prefix("RssAnon:"))
306 .and_then(|value| {
307 value
308 .trim()
309 .trim_end_matches("kB")
310 .trim()
311 .parse::<u64>()
312 .ok()
313 })
314 .map(|kib| kib * 1024)
315}
316
317#[cfg(not(target_os = "linux"))]
318pub fn process_memory_usage() -> Option<u64> {
319 None
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
329 fn single_component_accepts_plain_file_names() {
330 assert!(is_single_component_file_name("wings.log"));
331 assert!(is_single_component_file_name("wings.log.gz"));
332 assert!(is_single_component_file_name(".env"));
333 assert!(is_single_component_file_name("..hidden"));
334 }
335
336 #[test]
337 fn single_component_rejects_the_wings_endpoint_traversals() {
338 assert!(!is_single_component_file_name("../config"));
339 assert!(!is_single_component_file_name(
340 "../../servers/uuid/files/contents"
341 ));
342 assert!(!is_single_component_file_name("/api/system/config"));
343 assert!(!is_single_component_file_name("wings.log/../../config"));
344 assert!(!is_single_component_file_name("a/b"));
345 assert!(!is_single_component_file_name("wings.log/"));
346 }
347
348 #[test]
349 fn single_component_rejects_empty_and_dot_names() {
350 assert!(!is_single_component_file_name(""));
351 assert!(!is_single_component_file_name("."));
352 assert!(!is_single_component_file_name(".."));
353 assert!(!is_single_component_file_name("./x"));
354 }
355
356 #[test]
359 fn redact_query_leaves_harmless_queries_borrowed() {
360 let query = "directory=%2F&ignored=&per_page=100&page=1";
361
362 assert!(matches!(redact_query(query), std::borrow::Cow::Borrowed(_)));
363 assert_eq!(redact_query(query), query);
364 assert_eq!(redact_query(""), "");
365 }
366
367 #[test]
368 fn redact_query_redacts_the_download_and_upload_tokens() {
369 assert_eq!(
370 redact_query(
371 "token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzY29wZSI6ImJhY2t1cC1kb3dubG9hZCJ9.sig"
372 ),
373 "token=<redacted>"
374 );
375 assert_eq!(
376 redact_query("token=eyJ0eXAi.eyJzY29wZSJ9.sig&archive_format=tar_gz"),
377 "token=<redacted>&archive_format=tar_gz"
378 );
379 }
380
381 #[test]
382 fn redact_query_redacts_the_password_reset_and_oauth_parameters() {
383 assert_eq!(
384 redact_query("token=6RnJvbTNoZVNoYWRvd3NPZlRoZURlZXBXZUNhbGxUb1RoZWU"),
385 "token=<redacted>"
386 );
387 assert_eq!(
388 redact_query("code=abc123&state=xyz"),
389 "code=<redacted>&state=xyz"
390 );
391 assert_eq!(redact_query("data=eyJ1c2VyIjp7fX0="), "data=<redacted>");
392 }
393
394 #[test]
395 fn redact_query_matches_keys_case_insensitively_and_by_substring() {
396 assert_eq!(redact_query("Token=abc"), "Token=<redacted>");
397 assert_eq!(redact_query("access_token=abc"), "access_token=<redacted>");
398 assert_eq!(redact_query("api_key=abc"), "api_key=<redacted>");
399 assert_eq!(
400 redact_query("X-Amz-Signature=abc&X-Amz-Credential=def&X-Amz-Date=20260825T000000Z"),
401 "X-Amz-Signature=<redacted>&X-Amz-Credential=<redacted>&X-Amz-Date=20260825T000000Z"
402 );
403 }
404
405 #[test]
406 fn redact_query_keeps_valueless_and_malformed_pairs_intact() {
407 assert_eq!(redact_query("token"), "token");
408 assert_eq!(redact_query("token="), "token=<redacted>");
409 assert_eq!(redact_query("&&"), "&&");
410 assert_eq!(redact_query("token=a=b&x=1"), "token=<redacted>&x=1");
411 }
412}