1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, thiserror::Error)]
7pub enum ValidationError {
8 #[error("Project name is invalid: {reason}")]
9 InvalidProjectName { reason: String },
10
11 #[error("Project path is invalid: {reason}")]
12 InvalidProjectPath { reason: String },
13
14 #[error("Session parameter is invalid: {field} - {reason}")]
15 InvalidSessionParameter { field: String, reason: String },
16
17 #[error("Date range is invalid: {reason}")]
18 InvalidDateRange { reason: String },
19
20 #[error("Input string is invalid: {reason}")]
21 InvalidString { reason: String },
22
23 #[error("Numeric value is invalid: {field} - {reason}")]
24 InvalidNumeric { field: String, reason: String },
25}
26
27pub fn validate_project_name(name: &str) -> Result<String> {
29 let trimmed = name.trim();
30
31 if trimmed.is_empty() {
32 return Err(ValidationError::InvalidProjectName {
33 reason: "Project name cannot be empty or whitespace only".to_string(),
34 }
35 .into());
36 }
37
38 if trimmed.len() > 255 {
39 return Err(ValidationError::InvalidProjectName {
40 reason: format!(
41 "Project name too long (max 255 characters, got {})",
42 trimmed.len()
43 ),
44 }
45 .into());
46 }
47
48 if trimmed.len() < 2 {
49 return Err(ValidationError::InvalidProjectName {
50 reason: "Project name must be at least 2 characters long".to_string(),
51 }
52 .into());
53 }
54
55 let dangerous_chars = ['\0', '/', '\\', ':', '*', '?', '"', '<', '>', '|'];
57 if let Some(bad_char) = dangerous_chars.iter().find(|&&c| trimmed.contains(c)) {
58 return Err(ValidationError::InvalidProjectName {
59 reason: format!("Project name contains invalid character: '{}'", bad_char),
60 }
61 .into());
62 }
63
64 let reserved_names = [".", "..", "CON", "PRN", "AUX", "NUL", "CLOCK$"];
66 let upper_name = trimmed.to_uppercase();
67 if reserved_names.contains(&upper_name.as_str()) {
68 return Err(ValidationError::InvalidProjectName {
69 reason: format!("'{}' is a reserved name and cannot be used", trimmed),
70 }
71 .into());
72 }
73
74 let windows_reserved = [
76 "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2",
77 "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
78 ];
79 if windows_reserved.contains(&upper_name.as_str()) {
80 return Err(ValidationError::InvalidProjectName {
81 reason: format!("'{}' is a Windows reserved device name", trimmed),
82 }
83 .into());
84 }
85
86 if trimmed.starts_with('.') && trimmed.len() <= 3 {
88 return Err(ValidationError::InvalidProjectName {
89 reason: "Project name cannot start with '.' (hidden files/directories)".to_string(),
90 }
91 .into());
92 }
93
94 Ok(trimmed.to_string())
95}
96
97pub fn validate_project_description(description: &str) -> Result<String> {
99 let trimmed = description.trim();
100
101 if trimmed.len() > 1000 {
102 return Err(ValidationError::InvalidString {
103 reason: format!(
104 "Description too long (max 1000 characters, got {})",
105 trimmed.len()
106 ),
107 }
108 .into());
109 }
110
111 if trimmed.contains('\0') {
113 return Err(ValidationError::InvalidString {
114 reason: "Description contains null bytes".to_string(),
115 }
116 .into());
117 }
118
119 Ok(trimmed.to_string())
120}
121
122pub fn validate_project_id(id: i64) -> Result<i64> {
124 if id <= 0 {
125 return Err(ValidationError::InvalidNumeric {
126 field: "project_id".to_string(),
127 reason: format!("Project ID must be positive (got {})", id),
128 }
129 .into());
130 }
131
132 if id > i64::MAX / 2 {
133 return Err(ValidationError::InvalidNumeric {
134 field: "project_id".to_string(),
135 reason: "Project ID too large".to_string(),
136 }
137 .into());
138 }
139
140 Ok(id)
141}
142
143pub fn validate_session_id(id: i64) -> Result<i64> {
145 if id <= 0 {
146 return Err(ValidationError::InvalidNumeric {
147 field: "session_id".to_string(),
148 reason: format!("Session ID must be positive (got {})", id),
149 }
150 .into());
151 }
152
153 Ok(id)
154}
155
156pub fn validate_date_range(
158 from: Option<DateTime<Utc>>,
159 to: Option<DateTime<Utc>>,
160) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
161 let now = Utc::now();
162
163 let to_date = to.unwrap_or(now);
164 let from_date = from.unwrap_or_else(|| to_date - chrono::Duration::days(30));
165
166 if from_date > to_date {
167 return Err(ValidationError::InvalidDateRange {
168 reason: format!(
169 "Start date ({}) must be before end date ({})",
170 from_date.format("%Y-%m-%d %H:%M:%S"),
171 to_date.format("%Y-%m-%d %H:%M:%S")
172 ),
173 }
174 .into());
175 }
176
177 let max_range = chrono::Duration::days(3650); if to_date - from_date > max_range {
180 return Err(ValidationError::InvalidDateRange {
181 reason: "Date range too large (maximum 10 years)".to_string(),
182 }
183 .into());
184 }
185
186 if to_date > now + chrono::Duration::hours(1) {
188 return Err(ValidationError::InvalidDateRange {
189 reason: "End date cannot be more than 1 hour in the future".to_string(),
190 }
191 .into());
192 }
193
194 Ok((from_date, to_date))
195}
196
197pub fn validate_query_limit(limit: Option<usize>) -> Result<usize> {
199 let limit = limit.unwrap_or(10);
200
201 if limit == 0 {
202 return Err(ValidationError::InvalidNumeric {
203 field: "limit".to_string(),
204 reason: "Limit must be greater than 0".to_string(),
205 }
206 .into());
207 }
208
209 if limit > 10000 {
210 return Err(ValidationError::InvalidNumeric {
211 field: "limit".to_string(),
212 reason: "Limit too large (maximum 10,000)".to_string(),
213 }
214 .into());
215 }
216
217 Ok(limit)
218}
219
220pub fn validate_session_notes(notes: &str) -> Result<String> {
222 let trimmed = notes.trim();
223
224 if trimmed.len() > 2000 {
225 return Err(ValidationError::InvalidString {
226 reason: format!(
227 "Notes too long (max 2000 characters, got {})",
228 trimmed.len()
229 ),
230 }
231 .into());
232 }
233
234 if trimmed.contains('\0') {
236 return Err(ValidationError::InvalidString {
237 reason: "Notes contain null bytes".to_string(),
238 }
239 .into());
240 }
241
242 Ok(trimmed.to_string())
243}
244
245pub fn validate_project_path_enhanced(path: &Path) -> Result<PathBuf> {
247 super::paths::validate_project_path(path).context("Path failed security validation")
249}
250
251pub fn validate_process_id(pid: u32) -> Result<u32> {
253 if pid == 0 {
254 return Err(ValidationError::InvalidNumeric {
255 field: "process_id".to_string(),
256 reason: "Process ID cannot be 0".to_string(),
257 }
258 .into());
259 }
260
261 Ok(pid)
262}
263
264pub fn validate_tag_name(tag: &str) -> Result<String> {
266 let trimmed = tag.trim();
267
268 if trimmed.is_empty() {
269 return Err(ValidationError::InvalidString {
270 reason: "Tag name cannot be empty".to_string(),
271 }
272 .into());
273 }
274
275 if trimmed.len() > 50 {
276 return Err(ValidationError::InvalidString {
277 reason: format!(
278 "Tag name too long (max 50 characters, got {})",
279 trimmed.len()
280 ),
281 }
282 .into());
283 }
284
285 if !trimmed
287 .chars()
288 .all(|c| c.is_alphanumeric() || "-_".contains(c))
289 {
290 return Err(ValidationError::InvalidString {
291 reason: "Tag name can only contain letters, numbers, hyphens, and underscores"
292 .to_string(),
293 }
294 .into());
295 }
296
297 Ok(trimmed.to_lowercase())
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn test_validate_project_name() {
306 assert!(validate_project_name("my-project").is_ok());
308 assert!(validate_project_name(" ProjectName ").is_ok());
309 assert!(validate_project_name("Valid_Project123").is_ok());
310
311 assert!(validate_project_name("").is_err());
313 assert!(validate_project_name(" ").is_err());
314 assert!(validate_project_name("a").is_err()); assert!(validate_project_name("project/with/slash").is_err());
316 assert!(validate_project_name("project\0null").is_err());
317 assert!(validate_project_name("CON").is_err()); assert!(validate_project_name("COM1").is_err()); let long_name = "a".repeat(300);
322 assert!(validate_project_name(&long_name).is_err());
323 }
324
325 #[test]
326 fn test_validate_project_id() {
327 assert!(validate_project_id(1).is_ok());
328 assert!(validate_project_id(1000).is_ok());
329
330 assert!(validate_project_id(0).is_err());
331 assert!(validate_project_id(-1).is_err());
332 }
333
334 #[test]
335 fn test_validate_date_range() {
336 let now = Utc::now();
337 let yesterday = now - chrono::Duration::days(1);
338
339 assert!(validate_date_range(Some(yesterday), Some(now)).is_ok());
341
342 assert!(validate_date_range(Some(now), Some(yesterday)).is_err());
344
345 let future = now + chrono::Duration::days(1);
347 assert!(validate_date_range(Some(yesterday), Some(future)).is_err());
348 }
349
350 #[test]
351 fn test_validate_query_limit() {
352 assert_eq!(validate_query_limit(Some(100)).unwrap(), 100);
353 assert_eq!(validate_query_limit(None).unwrap(), 10); assert!(validate_query_limit(Some(0)).is_err());
356 assert!(validate_query_limit(Some(20000)).is_err()); }
358
359 #[test]
360 fn test_validate_tag_name() {
361 assert_eq!(validate_tag_name("Work").unwrap(), "work");
362 assert_eq!(
363 validate_tag_name(" project-tag_123 ").unwrap(),
364 "project-tag_123"
365 );
366
367 assert!(validate_tag_name("").is_err());
368 assert!(validate_tag_name("tag with spaces").is_err());
369 assert!(validate_tag_name("tag@special").is_err());
370
371 let long_tag = "a".repeat(60);
373 assert!(validate_tag_name(&long_tag).is_err());
374 }
375}