tempo_cli/utils/
validation.rs

1use anyhow::{Result, Context};
2use std::path::{Path, PathBuf};
3use chrono::{DateTime, Utc};
4
5/// Custom error types for better error handling
6#[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
27/// Comprehensive project name validation
28pub 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        }.into());
35    }
36    
37    if trimmed.len() > 255 {
38        return Err(ValidationError::InvalidProjectName {
39            reason: format!("Project name too long (max 255 characters, got {})", trimmed.len())
40        }.into());
41    }
42    
43    if trimmed.len() < 2 {
44        return Err(ValidationError::InvalidProjectName {
45            reason: "Project name must be at least 2 characters long".to_string()
46        }.into());
47    }
48    
49    // Check for dangerous characters
50    let dangerous_chars = ['\0', '/', '\\', ':', '*', '?', '"', '<', '>', '|'];
51    if let Some(bad_char) = dangerous_chars.iter().find(|&&c| trimmed.contains(c)) {
52        return Err(ValidationError::InvalidProjectName {
53            reason: format!("Project name contains invalid character: '{}'", bad_char)
54        }.into());
55    }
56    
57    // Check for reserved names
58    let reserved_names = [".", "..", "CON", "PRN", "AUX", "NUL", "CLOCK$"];
59    let upper_name = trimmed.to_uppercase();
60    if reserved_names.contains(&upper_name.as_str()) {
61        return Err(ValidationError::InvalidProjectName {
62            reason: format!("'{}' is a reserved name and cannot be used", trimmed)
63        }.into());
64    }
65    
66    // Check for Windows reserved device names
67    let windows_reserved = ["COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", 
68                           "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"];
69    if windows_reserved.contains(&upper_name.as_str()) {
70        return Err(ValidationError::InvalidProjectName {
71            reason: format!("'{}' is a Windows reserved device name", trimmed)
72        }.into());
73    }
74    
75    // Check for leading/trailing dots or spaces
76    if trimmed.starts_with('.') && trimmed.len() <= 3 {
77        return Err(ValidationError::InvalidProjectName {
78            reason: "Project name cannot start with '.' (hidden files/directories)".to_string()
79        }.into());
80    }
81    
82    Ok(trimmed.to_string())
83}
84
85/// Validate project description
86pub fn validate_project_description(description: &str) -> Result<String> {
87    let trimmed = description.trim();
88    
89    if trimmed.len() > 1000 {
90        return Err(ValidationError::InvalidString {
91            reason: format!("Description too long (max 1000 characters, got {})", trimmed.len())
92        }.into());
93    }
94    
95    // Check for null bytes
96    if trimmed.contains('\0') {
97        return Err(ValidationError::InvalidString {
98            reason: "Description contains null bytes".to_string()
99        }.into());
100    }
101    
102    Ok(trimmed.to_string())
103}
104
105/// Validate project ID
106pub fn validate_project_id(id: i64) -> Result<i64> {
107    if id <= 0 {
108        return Err(ValidationError::InvalidNumeric {
109            field: "project_id".to_string(),
110            reason: format!("Project ID must be positive (got {})", id)
111        }.into());
112    }
113    
114    if id > i64::MAX / 2 {
115        return Err(ValidationError::InvalidNumeric {
116            field: "project_id".to_string(), 
117            reason: "Project ID too large".to_string()
118        }.into());
119    }
120    
121    Ok(id)
122}
123
124/// Validate session ID
125pub fn validate_session_id(id: i64) -> Result<i64> {
126    if id <= 0 {
127        return Err(ValidationError::InvalidNumeric {
128            field: "session_id".to_string(),
129            reason: format!("Session ID must be positive (got {})", id)
130        }.into());
131    }
132    
133    Ok(id)
134}
135
136/// Validate date range for queries
137pub fn validate_date_range(from: Option<DateTime<Utc>>, to: Option<DateTime<Utc>>) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
138    let now = Utc::now();
139    
140    let to_date = to.unwrap_or(now);
141    let from_date = from.unwrap_or_else(|| to_date - chrono::Duration::days(30));
142    
143    if from_date > to_date {
144        return Err(ValidationError::InvalidDateRange {
145            reason: format!(
146                "Start date ({}) must be before end date ({})", 
147                from_date.format("%Y-%m-%d %H:%M:%S"),
148                to_date.format("%Y-%m-%d %H:%M:%S")
149            )
150        }.into());
151    }
152    
153    // Reasonable upper limit for date ranges
154    let max_range = chrono::Duration::days(3650); // ~10 years
155    if to_date - from_date > max_range {
156        return Err(ValidationError::InvalidDateRange {
157            reason: "Date range too large (maximum 10 years)".to_string()
158        }.into());
159    }
160    
161    // Don't allow future dates
162    if to_date > now + chrono::Duration::hours(1) {
163        return Err(ValidationError::InvalidDateRange {
164            reason: "End date cannot be more than 1 hour in the future".to_string()
165        }.into());
166    }
167    
168    Ok((from_date, to_date))
169}
170
171/// Validate limit parameter for queries
172pub fn validate_query_limit(limit: Option<usize>) -> Result<usize> {
173    let limit = limit.unwrap_or(10);
174    
175    if limit == 0 {
176        return Err(ValidationError::InvalidNumeric {
177            field: "limit".to_string(),
178            reason: "Limit must be greater than 0".to_string()
179        }.into());
180    }
181    
182    if limit > 10000 {
183        return Err(ValidationError::InvalidNumeric {
184            field: "limit".to_string(),
185            reason: "Limit too large (maximum 10,000)".to_string()
186        }.into());
187    }
188    
189    Ok(limit)
190}
191
192/// Validate session notes
193pub fn validate_session_notes(notes: &str) -> Result<String> {
194    let trimmed = notes.trim();
195    
196    if trimmed.len() > 2000 {
197        return Err(ValidationError::InvalidString {
198            reason: format!("Notes too long (max 2000 characters, got {})", trimmed.len())
199        }.into());
200    }
201    
202    // Check for null bytes
203    if trimmed.contains('\0') {
204        return Err(ValidationError::InvalidString {
205            reason: "Notes contain null bytes".to_string()
206        }.into());
207    }
208    
209    Ok(trimmed.to_string())
210}
211
212/// Validate path for project creation/access
213pub fn validate_project_path_enhanced(path: &Path) -> Result<PathBuf> {
214    // Use existing security validation
215    super::paths::validate_project_path(path)
216        .context("Path failed security validation")
217}
218
219/// Validate daemon process ID
220pub fn validate_process_id(pid: u32) -> Result<u32> {
221    if pid == 0 {
222        return Err(ValidationError::InvalidNumeric {
223            field: "process_id".to_string(),
224            reason: "Process ID cannot be 0".to_string()
225        }.into());
226    }
227    
228    Ok(pid)
229}
230
231/// Validate tag name for projects/sessions
232pub fn validate_tag_name(tag: &str) -> Result<String> {
233    let trimmed = tag.trim();
234    
235    if trimmed.is_empty() {
236        return Err(ValidationError::InvalidString {
237            reason: "Tag name cannot be empty".to_string()
238        }.into());
239    }
240    
241    if trimmed.len() > 50 {
242        return Err(ValidationError::InvalidString {
243            reason: format!("Tag name too long (max 50 characters, got {})", trimmed.len())
244        }.into());
245    }
246    
247    // Tags should be simple alphanumeric with limited special chars
248    if !trimmed.chars().all(|c| c.is_alphanumeric() || "-_".contains(c)) {
249        return Err(ValidationError::InvalidString {
250            reason: "Tag name can only contain letters, numbers, hyphens, and underscores".to_string()
251        }.into());
252    }
253    
254    Ok(trimmed.to_lowercase())
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_validate_project_name() {
263        // Valid names
264        assert!(validate_project_name("my-project").is_ok());
265        assert!(validate_project_name("  ProjectName  ").is_ok());
266        assert!(validate_project_name("Valid_Project123").is_ok());
267        
268        // Invalid names
269        assert!(validate_project_name("").is_err());
270        assert!(validate_project_name("   ").is_err());
271        assert!(validate_project_name("a").is_err()); // too short
272        assert!(validate_project_name("project/with/slash").is_err());
273        assert!(validate_project_name("project\0null").is_err());
274        assert!(validate_project_name("CON").is_err()); // reserved
275        assert!(validate_project_name("COM1").is_err()); // Windows reserved
276        
277        // Long name
278        let long_name = "a".repeat(300);
279        assert!(validate_project_name(&long_name).is_err());
280    }
281
282    #[test]
283    fn test_validate_project_id() {
284        assert!(validate_project_id(1).is_ok());
285        assert!(validate_project_id(1000).is_ok());
286        
287        assert!(validate_project_id(0).is_err());
288        assert!(validate_project_id(-1).is_err());
289    }
290
291    #[test]
292    fn test_validate_date_range() {
293        let now = Utc::now();
294        let yesterday = now - chrono::Duration::days(1);
295        
296        // Valid range
297        assert!(validate_date_range(Some(yesterday), Some(now)).is_ok());
298        
299        // Invalid range (from > to)
300        assert!(validate_date_range(Some(now), Some(yesterday)).is_err());
301        
302        // Future date
303        let future = now + chrono::Duration::days(1);
304        assert!(validate_date_range(Some(yesterday), Some(future)).is_err());
305    }
306
307    #[test]
308    fn test_validate_query_limit() {
309        assert_eq!(validate_query_limit(Some(100)).unwrap(), 100);
310        assert_eq!(validate_query_limit(None).unwrap(), 10); // default
311        
312        assert!(validate_query_limit(Some(0)).is_err());
313        assert!(validate_query_limit(Some(20000)).is_err()); // too large
314    }
315
316    #[test]
317    fn test_validate_tag_name() {
318        assert_eq!(validate_tag_name("Work").unwrap(), "work");
319        assert_eq!(validate_tag_name("  project-tag_123  ").unwrap(), "project-tag_123");
320        
321        assert!(validate_tag_name("").is_err());
322        assert!(validate_tag_name("tag with spaces").is_err());
323        assert!(validate_tag_name("tag@special").is_err());
324        
325        // Too long
326        let long_tag = "a".repeat(60);
327        assert!(validate_tag_name(&long_tag).is_err());
328    }
329}