tempo_cli/utils/
validation.rs1use anyhow::{Result, Context};
2use std::path::{Path, PathBuf};
3use chrono::{DateTime, Utc};
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 }.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 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 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 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 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
85pub 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 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
105pub 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
124pub 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
136pub 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 let max_range = chrono::Duration::days(3650); 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 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
171pub 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
192pub 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 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
212pub fn validate_project_path_enhanced(path: &Path) -> Result<PathBuf> {
214 super::paths::validate_project_path(path)
216 .context("Path failed security validation")
217}
218
219pub 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
231pub 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 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 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 assert!(validate_project_name("").is_err());
270 assert!(validate_project_name(" ").is_err());
271 assert!(validate_project_name("a").is_err()); 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()); assert!(validate_project_name("COM1").is_err()); 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 assert!(validate_date_range(Some(yesterday), Some(now)).is_ok());
298
299 assert!(validate_date_range(Some(now), Some(yesterday)).is_err());
301
302 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); assert!(validate_query_limit(Some(0)).is_err());
313 assert!(validate_query_limit(Some(20000)).is_err()); }
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 let long_tag = "a".repeat(60);
327 assert!(validate_tag_name(&long_tag).is_err());
328 }
329}