bws_web_server/core/utils/
mod.rs1use crate::core::error::{BwsError, BwsResult};
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10pub mod string {
12 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 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 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 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
86pub mod fs {
88 use super::*;
89
90 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 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 continue;
108 }
109 std::path::Component::ParentDir => {
110 normalized.pop();
112 }
113 _ => {
114 continue;
116 }
117 }
118 }
119
120 Ok(normalized)
121 }
122
123 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 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
162pub mod time {
164 use super::*;
165
166 pub fn unix_timestamp() -> u64 {
168 SystemTime::now()
169 .duration_since(UNIX_EPOCH)
170 .unwrap_or_default()
171 .as_secs()
172 }
173
174 pub fn format_iso8601(timestamp: SystemTime) -> String {
176 let datetime = chrono::DateTime::<chrono::Utc>::from(timestamp);
177 datetime.to_rfc3339()
178 }
179
180 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
188pub mod net {
190 use std::net::{IpAddr, SocketAddr};
191
192 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 pub fn extract_client_ip(
202 socket_addr: &SocketAddr,
203 x_forwarded_for: Option<&str>,
204 x_real_ip: Option<&str>,
205 ) -> IpAddr {
206 if let Some(real_ip) = x_real_ip {
208 if let Ok(ip) = real_ip.parse() {
209 return ip;
210 }
211 }
212
213 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 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}