Skip to main content

shared/
utils.rs

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
289/// serde-saphyr expands every alias into a full copy of its anchor, so a few hundred KB of
290/// aliases can materialize into many GB; YAML parsed by the panel never needs them.
291pub 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    // is_single_component_file_name
327
328    #[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    // redact_query
357
358    #[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}