bws_web_server/core/utils/
mod.rs

1//! Utility functions for BWS Web Server
2//!
3//! This module provides common utility functions used throughout
4//! the application for string manipulation, file operations, etc.
5
6use crate::core::error::{BwsError, BwsResult};
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10/// String manipulation utilities
11pub mod string {
12    /// Sanitize a string for use in file paths or URLs
13    pub fn sanitize_path_component(input: &str) -> String {
14        input
15            .chars()
16            .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
17            .collect()
18    }
19
20    /// Convert bytes to human-readable size
21    pub fn humanize_bytes(bytes: u64) -> String {
22        const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
23        const THRESHOLD: u64 = 1024;
24
25        if bytes < THRESHOLD {
26            return format!("{} B", bytes);
27        }
28
29        let mut size = bytes as f64;
30        let mut unit_index = 0;
31
32        while size >= THRESHOLD as f64 && unit_index < UNITS.len() - 1 {
33            size /= THRESHOLD as f64;
34            unit_index += 1;
35        }
36
37        format!("{:.1} {}", size, UNITS[unit_index])
38    }
39
40    /// Parse human-readable size to bytes
41    pub fn parse_size(input: &str) -> Option<u64> {
42        let input = input.trim().to_uppercase();
43
44        if let Ok(bytes) = input.parse::<u64>() {
45            return Some(bytes);
46        }
47
48        let (number_str, unit) = if input.ends_with("KB") {
49            (input.strip_suffix("KB")?, 1024)
50        } else if input.ends_with("MB") {
51            (input.strip_suffix("MB")?, 1024 * 1024)
52        } else if input.ends_with("GB") {
53            (input.strip_suffix("GB")?, 1024 * 1024 * 1024)
54        } else if input.ends_with("TB") {
55            (input.strip_suffix("TB")?, 1024_u64.pow(4))
56        } else if input.ends_with("B") {
57            (input.strip_suffix("B")?, 1)
58        } else {
59            return None;
60        };
61
62        let number: u64 = number_str.parse().ok()?;
63        Some(number * unit)
64    }
65
66    /// Format duration in human-readable format
67    pub fn humanize_duration(duration: std::time::Duration) -> String {
68        let total_seconds = duration.as_secs();
69        let days = total_seconds / 86400;
70        let hours = (total_seconds % 86400) / 3600;
71        let minutes = (total_seconds % 3600) / 60;
72        let seconds = total_seconds % 60;
73
74        if days > 0 {
75            format!("{}d {}h {}m {}s", days, hours, minutes, seconds)
76        } else if hours > 0 {
77            format!("{}h {}m {}s", hours, minutes, seconds)
78        } else if minutes > 0 {
79            format!("{}m {}s", minutes, seconds)
80        } else {
81            format!("{}s", seconds)
82        }
83    }
84}
85
86/// File system utilities
87pub mod fs {
88    use super::*;
89
90    /// Safely normalize a file path to prevent directory traversal
91    pub fn normalize_path<P: AsRef<Path>>(path: P) -> BwsResult<PathBuf> {
92        let path = path.as_ref();
93        let mut normalized = PathBuf::new();
94
95        for component in path.components() {
96            match component {
97                std::path::Component::Normal(name) => {
98                    // Check for dangerous filenames
99                    let name_str = name.to_string_lossy();
100                    if name_str.contains('\0') {
101                        return Err(BwsError::Validation("Path contains null byte".to_string()));
102                    }
103                    normalized.push(name);
104                }
105                std::path::Component::CurDir => {
106                    // Skip current directory references
107                    continue;
108                }
109                std::path::Component::ParentDir => {
110                    // Remove parent directory references for security
111                    normalized.pop();
112                }
113                _ => {
114                    // Skip other component types (Prefix, RootDir)
115                    continue;
116                }
117            }
118        }
119
120        Ok(normalized)
121    }
122
123    /// Check if a file extension is allowed for static serving
124    pub fn is_safe_extension(extension: &str) -> bool {
125        const SAFE_EXTENSIONS: &[&str] = &[
126            "html", "htm", "css", "js", "json", "xml", "txt", "md", "pdf", "doc", "docx", "jpg",
127            "jpeg", "png", "gif", "svg", "webp", "mp3", "mp4", "wav", "avi", "mov", "zip", "tar",
128            "gz", "woff", "woff2", "ttf", "ico", "manifest", "map", "wasm",
129        ];
130
131        SAFE_EXTENSIONS.contains(&extension.to_lowercase().as_str())
132    }
133
134    /// Get MIME type for file extension
135    pub fn get_mime_type(extension: &str) -> &'static str {
136        match extension.to_lowercase().as_str() {
137            "html" | "htm" => "text/html; charset=utf-8",
138            "css" => "text/css; charset=utf-8",
139            "js" => "application/javascript; charset=utf-8",
140            "json" => "application/json; charset=utf-8",
141            "xml" => "application/xml; charset=utf-8",
142            "txt" => "text/plain; charset=utf-8",
143            "md" => "text/markdown; charset=utf-8",
144            "pdf" => "application/pdf",
145            "jpg" | "jpeg" => "image/jpeg",
146            "png" => "image/png",
147            "gif" => "image/gif",
148            "svg" => "image/svg+xml",
149            "webp" => "image/webp",
150            "ico" => "image/x-icon",
151            "woff" => "font/woff",
152            "woff2" => "font/woff2",
153            "ttf" => "font/ttf",
154            "wasm" => "application/wasm",
155            "manifest" => "application/manifest+json",
156            "map" => "application/json",
157            _ => "application/octet-stream",
158        }
159    }
160}
161
162/// Time utilities
163pub mod time {
164    use super::*;
165
166    /// Get current Unix timestamp
167    pub fn unix_timestamp() -> u64 {
168        SystemTime::now()
169            .duration_since(UNIX_EPOCH)
170            .unwrap_or_default()
171            .as_secs()
172    }
173
174    /// Format timestamp as ISO 8601 string
175    pub fn format_iso8601(timestamp: SystemTime) -> String {
176        let datetime = chrono::DateTime::<chrono::Utc>::from(timestamp);
177        datetime.to_rfc3339()
178    }
179
180    /// Parse ISO 8601 string to SystemTime
181    pub fn parse_iso8601(input: &str) -> Option<SystemTime> {
182        chrono::DateTime::parse_from_rfc3339(input)
183            .ok()
184            .map(|dt| dt.into())
185    }
186}
187
188/// Network utilities
189pub mod net {
190    use std::net::{IpAddr, SocketAddr};
191
192    /// Check if an IP address is in a private range
193    pub fn is_private_ip(ip: &IpAddr) -> bool {
194        match ip {
195            IpAddr::V4(ipv4) => ipv4.is_private() || ipv4.is_loopback() || ipv4.is_link_local(),
196            IpAddr::V6(ipv6) => ipv6.is_loopback() || ((ipv6.segments()[0] & 0xfe00) == 0xfc00),
197        }
198    }
199
200    /// Extract client IP from various sources
201    pub fn extract_client_ip(
202        socket_addr: &SocketAddr,
203        x_forwarded_for: Option<&str>,
204        x_real_ip: Option<&str>,
205    ) -> IpAddr {
206        // Check X-Real-IP header first
207        if let Some(real_ip) = x_real_ip {
208            if let Ok(ip) = real_ip.parse() {
209                return ip;
210            }
211        }
212
213        // Check X-Forwarded-For header
214        if let Some(forwarded) = x_forwarded_for {
215            if let Some(first_ip) = forwarded.split(',').next() {
216                if let Ok(ip) = first_ip.trim().parse() {
217                    return ip;
218                }
219            }
220        }
221
222        // Fall back to socket address
223        socket_addr.ip()
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_humanize_bytes() {
233        assert_eq!(string::humanize_bytes(512), "512 B");
234        assert_eq!(string::humanize_bytes(1024), "1.0 KB");
235        assert_eq!(string::humanize_bytes(1536), "1.5 KB");
236        assert_eq!(string::humanize_bytes(1048576), "1.0 MB");
237    }
238
239    #[test]
240    fn test_parse_size() {
241        assert_eq!(string::parse_size("1024"), Some(1024));
242        assert_eq!(string::parse_size("1KB"), Some(1024));
243        assert_eq!(string::parse_size("1MB"), Some(1024 * 1024));
244        assert_eq!(string::parse_size("invalid"), None);
245    }
246
247    #[test]
248    fn test_normalize_path() {
249        let path = fs::normalize_path("../test/../file.txt").unwrap();
250        assert_eq!(path, PathBuf::from("file.txt"));
251
252        let path = fs::normalize_path("./folder/./file.txt").unwrap();
253        assert_eq!(path, PathBuf::from("folder/file.txt"));
254    }
255
256    #[test]
257    fn test_safe_extension() {
258        assert!(fs::is_safe_extension("html"));
259        assert!(fs::is_safe_extension("css"));
260        assert!(fs::is_safe_extension("js"));
261        assert!(!fs::is_safe_extension("exe"));
262        assert!(!fs::is_safe_extension("sh"));
263    }
264}