Skip to main content

sockudo_http/
util.rs

1use regex::Regex;
2use std::collections::BTreeMap;
3use std::fmt::Write;
4use std::sync::LazyLock;
5use subtle::ConstantTimeEq;
6
7// Pre-compiled regex patterns
8static SOCKET_ID_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d+\.\d+$").unwrap());
9
10static USER_ID_PATTERN: LazyLock<Regex> =
11    LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_\-=@,.;]+$").unwrap());
12
13/// Converts a map to an ordered array of key=value pairs
14pub fn to_ordered_array(map: &BTreeMap<String, String>) -> Vec<String> {
15    map.iter()
16        .map(|(key, value)| {
17            let mut result = String::with_capacity(key.len() + value.len() + 1);
18            write!(&mut result, "{}={}", key, value).unwrap();
19            result
20        })
21        .collect()
22}
23
24/// Calculates MD5 hash of the input
25/// Note: MD5 is used here for compatibility with the protocol, not for security
26pub fn get_md5(body: &str) -> String {
27    let digest = md5::compute(body.as_bytes());
28    hex::encode(digest.as_ref())
29}
30
31/// Constant-time string comparison to prevent timing attacks
32pub fn secure_compare(a: &str, b: &str) -> bool {
33    if a.len() != b.len() {
34        return false;
35    }
36
37    let a_bytes = a.as_bytes();
38    let b_bytes = b.as_bytes();
39
40    // Use the subtle crate for constant-time comparison
41    a_bytes.ct_eq(b_bytes).into()
42}
43
44/// Checks if a channel is encrypted (moved from channel.rs for backward compatibility)
45pub fn is_encrypted_channel(channel: &str) -> bool {
46    channel.starts_with("private-encrypted-")
47}
48
49/// Validates a channel name (moved to channel.rs but kept for backward compatibility)
50pub fn validate_channel(channel: &str) -> crate::Result<()> {
51    use crate::channel::Channel;
52    Channel::from_string(channel)?;
53    Ok(())
54}
55
56/// Validates that a channel name starts with `presence-`.
57pub fn validate_presence_channel(channel: &str) -> crate::Result<()> {
58    validate_channel(channel)?;
59    if !channel.starts_with("presence-") {
60        return Err(crate::SockudoError::Validation {
61            message: format!(
62                "Presence history is only available for presence channels: '{}'",
63                channel
64            ),
65        });
66    }
67    Ok(())
68}
69
70/// Validates a socket ID
71pub fn validate_socket_id(socket_id: &str) -> crate::Result<()> {
72    if !SOCKET_ID_PATTERN.is_match(socket_id) {
73        return Err(crate::SockudoError::Validation {
74            message: format!(
75                "Invalid socket id: '{}'. Must be in format: \\d+.\\d+",
76                socket_id
77            ),
78        });
79    }
80    Ok(())
81}
82
83/// Validates a user ID
84pub fn validate_user_id(user_id: &str) -> crate::Result<()> {
85    if user_id.is_empty() {
86        return Err(crate::SockudoError::Validation {
87            message: "User ID cannot be empty".to_string(),
88        });
89    }
90
91    if user_id.len() > 200 {
92        return Err(crate::SockudoError::Validation {
93            message: format!("User ID too long: '{}' (max 200 characters)", user_id),
94        });
95    }
96
97    if !USER_ID_PATTERN.is_match(user_id) {
98        return Err(crate::SockudoError::Validation {
99            message: format!(
100                "Invalid user ID: '{}'. Must match pattern: [a-zA-Z0-9_\\-=@,.;]+",
101                user_id
102            ),
103        });
104    }
105
106    Ok(())
107}
108
109/// Efficiently joins strings with a separator
110pub fn join_strings<'a, I>(items: I, separator: &str) -> String
111where
112    I: IntoIterator<Item = &'a str>,
113    I::IntoIter: ExactSizeIterator,
114{
115    let iter = items.into_iter();
116    let (lower_bound, _) = iter.size_hint();
117
118    // Pre-allocate with estimated size
119    let mut result = String::with_capacity(lower_bound * 20); // Rough estimate
120
121    for (i, item) in iter.enumerate() {
122        if i > 0 {
123            result.push_str(separator);
124        }
125        result.push_str(item);
126    }
127
128    result
129}
130
131/// Creates a timestamp string for the current time
132pub fn current_timestamp() -> String {
133    std::time::SystemTime::now()
134        .duration_since(std::time::UNIX_EPOCH)
135        .unwrap()
136        .as_secs()
137        .to_string()
138}
139
140/// Formats a duration in a human-readable way
141pub fn format_duration(duration: std::time::Duration) -> String {
142    let secs = duration.as_secs();
143    if secs < 60 {
144        format!("{}s", secs)
145    } else if secs < 3600 {
146        format!("{}m {}s", secs / 60, secs % 60)
147    } else {
148        format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_secure_compare() {
158        assert!(secure_compare("hello", "hello"));
159        assert!(!secure_compare("hello", "world"));
160        assert!(!secure_compare("hello", "hello!"));
161        assert!(!secure_compare("hello", "hell"));
162    }
163
164    #[test]
165    fn test_is_encrypted_channel() {
166        assert!(is_encrypted_channel("private-encrypted-test"));
167        assert!(!is_encrypted_channel("private-test"));
168        assert!(!is_encrypted_channel("public-test"));
169    }
170
171    #[test]
172    fn test_validate_socket_id() {
173        assert!(validate_socket_id("123.456").is_ok());
174        assert!(validate_socket_id("0.0").is_ok());
175        assert!(validate_socket_id("123").is_err());
176        assert!(validate_socket_id("123.456.789").is_err());
177        assert!(validate_socket_id("abc.def").is_err());
178    }
179
180    #[test]
181    fn test_validate_user_id() {
182        assert!(validate_user_id("user123").is_ok());
183        assert!(validate_user_id("user-123_test@example.com").is_ok());
184        assert!(validate_user_id("").is_err());
185        assert!(validate_user_id(&"a".repeat(201)).is_err());
186        assert!(validate_user_id("user with spaces").is_err());
187    }
188
189    #[test]
190    fn test_join_strings() {
191        let items = ["a", "b", "c"];
192        assert_eq!(join_strings(items.iter().copied(), ","), "a,b,c");
193        assert_eq!(join_strings(["single"].iter().copied(), ","), "single");
194        assert_eq!(join_strings(Vec::<&str>::new().iter().copied(), ","), "");
195    }
196
197    #[test]
198    fn test_format_duration() {
199        use std::time::Duration;
200
201        assert_eq!(format_duration(Duration::from_secs(30)), "30s");
202        assert_eq!(format_duration(Duration::from_secs(90)), "1m 30s");
203        assert_eq!(format_duration(Duration::from_secs(3661)), "1h 1m");
204    }
205
206    #[test]
207    fn test_to_ordered_array() {
208        let mut map = BTreeMap::new();
209        map.insert("key1".to_string(), "value1".to_string());
210        map.insert("key2".to_string(), "value2".to_string());
211
212        let result = to_ordered_array(&map);
213        assert_eq!(result, vec!["key1=value1", "key2=value2"]);
214    }
215
216    #[test]
217    fn test_get_md5() {
218        let hash = get_md5("hello");
219        assert_eq!(hash, "5d41402abc4b2a76b9719d911017c592");
220    }
221}