tempo_cli/utils/
validation.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use std::path::{Path, PathBuf};
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        }
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    // Check for dangerous characters
56    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    // Check for reserved names
65    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    // Check for Windows reserved device names
75    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    // Check for leading/trailing dots or spaces
87    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
97/// Validate project description
98pub 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    // Check for null bytes
112    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
122/// Validate project ID
123pub 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
143/// Validate session ID
144pub 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
156/// Validate date range for queries
157pub 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    // Reasonable upper limit for date ranges
178    let max_range = chrono::Duration::days(3650); // ~10 years
179    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    // Don't allow future dates
187    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
197/// Validate limit parameter for queries
198pub 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
220/// Validate session notes
221pub 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    // Check for null bytes
235    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
245/// Validate path for project creation/access
246pub fn validate_project_path_enhanced(path: &Path) -> Result<PathBuf> {
247    // Use existing security validation
248    super::paths::validate_project_path(path).context("Path failed security validation")
249}
250
251/// Validate daemon process ID
252pub 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
264/// Validate tag name for projects/sessions
265pub 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    // Tags should be simple alphanumeric with limited special chars
286    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        // Valid names
307        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        // Invalid names
312        assert!(validate_project_name("").is_err());
313        assert!(validate_project_name("   ").is_err());
314        assert!(validate_project_name("a").is_err()); // too short
315        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()); // reserved
318        assert!(validate_project_name("COM1").is_err()); // Windows reserved
319
320        // Long name
321        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        // Valid range
340        assert!(validate_date_range(Some(yesterday), Some(now)).is_ok());
341
342        // Invalid range (from > to)
343        assert!(validate_date_range(Some(now), Some(yesterday)).is_err());
344
345        // Future date
346        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); // default
354
355        assert!(validate_query_limit(Some(0)).is_err());
356        assert!(validate_query_limit(Some(20000)).is_err()); // too large
357    }
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        // Too long
372        let long_tag = "a".repeat(60);
373        assert!(validate_tag_name(&long_tag).is_err());
374    }
375}