vika_cli/config/
validator.rs

1use crate::config::model::Config;
2use crate::error::{ConfigError, Result};
3use std::path::{Path, PathBuf};
4
5pub fn validate_config(config: &Config) -> Result<()> {
6    // Validate that at least one spec is defined
7    if config.specs.is_empty() {
8        return Err(ConfigError::NoSpecDefined.into());
9    }
10
11    // Validate specs configuration
12    // Check for duplicate names
13    let mut seen_names = std::collections::HashSet::new();
14    for spec in &config.specs {
15        if seen_names.contains(&spec.name) {
16            return Err(ConfigError::DuplicateSpecName {
17                name: spec.name.clone(),
18            }
19            .into());
20        }
21        seen_names.insert(&spec.name);
22
23        // Validate spec name
24        if spec.name.is_empty() {
25            return Err(ConfigError::InvalidSpecName {
26                name: spec.name.clone(),
27            }
28            .into());
29        }
30
31        // Validate spec name format (alphanumeric, hyphens, underscores only)
32        if !spec
33            .name
34            .chars()
35            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
36        {
37            return Err(ConfigError::InvalidSpecName {
38                name: spec.name.clone(),
39            }
40            .into());
41        }
42
43        // Validate spec path is not empty
44        if spec.path.is_empty() {
45            return Err(ConfigError::Invalid {
46                message: format!("Spec '{}' has an empty path", spec.name),
47            }
48            .into());
49        }
50
51        // Validate per-spec schemas output path
52        let schemas_output = PathBuf::from(&spec.schemas.output);
53        if schemas_output.is_absolute() {
54            validate_safe_path(&schemas_output)?;
55        }
56
57        // Validate per-spec apis output path
58        let apis_output = PathBuf::from(&spec.apis.output);
59        if apis_output.is_absolute() {
60            validate_safe_path(&apis_output)?;
61        }
62
63        // Validate per-spec API style
64        if spec.apis.style != "fetch" {
65            return Err(ConfigError::Invalid {
66                message: format!(
67                    "Unsupported API style for spec '{}': {}. Only 'fetch' is supported.",
68                    spec.name, spec.apis.style
69                ),
70            }
71            .into());
72        }
73    }
74
75    // Validate root_dir
76    let root_dir = PathBuf::from(&config.root_dir);
77    if root_dir.is_absolute() && !root_dir.exists() {
78        return Err(ConfigError::Invalid {
79            message: format!("Root directory does not exist: {}", config.root_dir),
80        }
81        .into());
82    }
83
84    Ok(())
85}
86
87fn validate_safe_path(path: &Path) -> Result<()> {
88    // Prevent writing to system directories
89    let path_str = path.to_string_lossy();
90
91    if path_str.contains("/etc/")
92        || path_str.contains("/usr/")
93        || path_str.contains("/bin/")
94        || path_str.contains("/sbin/")
95        || path_str.contains("/var/")
96        || path_str.contains("/opt/")
97        || path_str == "/"
98        || path_str == "/root"
99    {
100        return Err(ConfigError::InvalidOutputDirectory {
101            path: path_str.to_string(),
102        }
103        .into());
104    }
105
106    Ok(())
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::config::model::Config;
113
114    #[test]
115    fn test_validate_config_valid() {
116        let mut config = Config::default();
117        config.specs = vec![crate::config::model::SpecEntry {
118            name: "test".to_string(),
119            path: "test.yaml".to_string(),
120            schemas: crate::config::model::SchemasConfig::default(),
121            apis: crate::config::model::ApisConfig::default(),
122            hooks: None,
123            modules: crate::config::model::ModulesConfig::default(),
124        }];
125        assert!(validate_config(&config).is_ok());
126    }
127
128    #[test]
129    fn test_validate_config_invalid_style() {
130        let apis = crate::config::model::ApisConfig {
131            style: "invalid".to_string(),
132            ..Default::default()
133        };
134        let config = Config {
135            specs: vec![crate::config::model::SpecEntry {
136                name: "test".to_string(),
137                path: "test.yaml".to_string(),
138                schemas: crate::config::model::SchemasConfig::default(),
139                apis,
140                hooks: None,
141                modules: crate::config::model::ModulesConfig::default(),
142            }],
143            ..Default::default()
144        };
145
146        let result = validate_config(&config);
147        assert!(result.is_err());
148        let error = result.unwrap_err();
149        assert!(error.to_string().contains("Unsupported API style"));
150    }
151
152    #[test]
153    fn test_validate_safe_path_etc() {
154        let path = PathBuf::from("/etc/test");
155        let result = validate_safe_path(&path);
156        assert!(result.is_err());
157    }
158
159    #[test]
160    fn test_validate_safe_path_usr() {
161        let path = PathBuf::from("/usr/test");
162        let result = validate_safe_path(&path);
163        assert!(result.is_err());
164    }
165
166    #[test]
167    fn test_validate_safe_path_bin() {
168        let path = PathBuf::from("/bin/test");
169        let result = validate_safe_path(&path);
170        assert!(result.is_err());
171    }
172
173    #[test]
174    fn test_validate_safe_path_root() {
175        let path = PathBuf::from("/");
176        let result = validate_safe_path(&path);
177        assert!(result.is_err());
178    }
179
180    #[test]
181    fn test_validate_safe_path_valid() {
182        let path = PathBuf::from("/home/user/project");
183        let result = validate_safe_path(&path);
184        assert!(result.is_ok());
185    }
186
187    #[test]
188    fn test_validate_config_absolute_paths() {
189        let schemas = crate::config::model::SchemasConfig {
190            output: "/home/user/schemas".to_string(),
191            ..Default::default()
192        };
193        let apis = crate::config::model::ApisConfig {
194            output: "/home/user/apis".to_string(),
195            ..Default::default()
196        };
197        let config = Config {
198            specs: vec![crate::config::model::SpecEntry {
199                name: "test".to_string(),
200                path: "test.yaml".to_string(),
201                schemas,
202                apis,
203                hooks: None,
204                modules: crate::config::model::ModulesConfig::default(),
205            }],
206            ..Default::default()
207        };
208
209        let result = validate_config(&config);
210        assert!(result.is_ok());
211    }
212
213    #[test]
214    fn test_validate_config_unsafe_schemas_path() {
215        let schemas = crate::config::model::SchemasConfig {
216            output: "/etc/schemas".to_string(),
217            ..Default::default()
218        };
219        let config = Config {
220            specs: vec![crate::config::model::SpecEntry {
221                name: "test".to_string(),
222                path: "test.yaml".to_string(),
223                schemas,
224                apis: crate::config::model::ApisConfig::default(),
225                hooks: None,
226                modules: crate::config::model::ModulesConfig::default(),
227            }],
228            ..Default::default()
229        };
230
231        let result = validate_config(&config);
232        assert!(result.is_err());
233    }
234
235    #[test]
236    fn test_validate_config_unsafe_apis_path() {
237        let apis = crate::config::model::ApisConfig {
238            output: "/usr/apis".to_string(),
239            ..Default::default()
240        };
241        let config = Config {
242            specs: vec![crate::config::model::SpecEntry {
243                name: "test".to_string(),
244                path: "test.yaml".to_string(),
245                schemas: crate::config::model::SchemasConfig::default(),
246                apis,
247                hooks: None,
248                modules: crate::config::model::ModulesConfig::default(),
249            }],
250            ..Default::default()
251        };
252
253        let result = validate_config(&config);
254        assert!(result.is_err());
255    }
256
257    #[test]
258    fn test_validate_config_no_spec_defined() {
259        let config = Config::default();
260        // Default config has no specs, so this should fail
261        let result = validate_config(&config);
262        assert!(result.is_err());
263        let error = result.unwrap_err();
264        assert!(error.to_string().contains("No specs are defined"));
265    }
266
267    #[test]
268    fn test_validate_config_empty_specs_array() {
269        let config = Config {
270            specs: vec![],
271            ..Default::default()
272        };
273
274        let result = validate_config(&config);
275        assert!(result.is_err());
276        let error = result.unwrap_err();
277        assert!(error.to_string().contains("No specs are defined"));
278    }
279
280    #[test]
281    fn test_validate_config_duplicate_spec_names() {
282        let mut config = Config::default();
283        config.specs = vec![
284            crate::config::model::SpecEntry {
285                name: "auth".to_string(),
286                path: "specs/auth.yaml".to_string(),
287                schemas: crate::config::model::SchemasConfig::default(),
288                apis: crate::config::model::ApisConfig::default(),
289                hooks: None,
290                modules: crate::config::model::ModulesConfig::default(),
291            },
292            crate::config::model::SpecEntry {
293                name: "auth".to_string(),
294                path: "specs/auth2.yaml".to_string(),
295                schemas: crate::config::model::SchemasConfig::default(),
296                apis: crate::config::model::ApisConfig::default(),
297                hooks: None,
298                modules: crate::config::model::ModulesConfig::default(),
299            },
300        ];
301
302        let result = validate_config(&config);
303        assert!(result.is_err());
304        let error = result.unwrap_err();
305        assert!(error.to_string().contains("Duplicate spec name"));
306    }
307
308    #[test]
309    fn test_validate_config_invalid_spec_name() {
310        let mut config = Config::default();
311        config.specs = vec![crate::config::model::SpecEntry {
312            name: "invalid name".to_string(), // contains space
313            path: "specs/auth.yaml".to_string(),
314            schemas: crate::config::model::SchemasConfig::default(),
315            apis: crate::config::model::ApisConfig::default(),
316            hooks: None,
317            modules: crate::config::model::ModulesConfig::default(),
318        }];
319
320        let result = validate_config(&config);
321        assert!(result.is_err());
322        let error = result.unwrap_err();
323        assert!(error.to_string().contains("Invalid spec name"));
324    }
325
326    #[test]
327    fn test_validate_config_empty_spec_name() {
328        let mut config = Config::default();
329        config.specs = vec![crate::config::model::SpecEntry {
330            name: "".to_string(),
331            path: "specs/auth.yaml".to_string(),
332            schemas: crate::config::model::SchemasConfig::default(),
333            apis: crate::config::model::ApisConfig::default(),
334            hooks: None,
335            modules: crate::config::model::ModulesConfig::default(),
336        }];
337
338        let result = validate_config(&config);
339        assert!(result.is_err());
340        let error = result.unwrap_err();
341        assert!(error.to_string().contains("Invalid spec name"));
342    }
343
344    #[test]
345    fn test_validate_config_empty_spec_path() {
346        let mut config = Config::default();
347        config.specs = vec![crate::config::model::SpecEntry {
348            name: "auth".to_string(),
349            path: "".to_string(),
350            schemas: crate::config::model::SchemasConfig::default(),
351            apis: crate::config::model::ApisConfig::default(),
352            hooks: None,
353            modules: crate::config::model::ModulesConfig::default(),
354        }];
355
356        let result = validate_config(&config);
357        assert!(result.is_err());
358        let error = result.unwrap_err();
359        assert!(error.to_string().contains("empty path"));
360    }
361
362    #[test]
363    fn test_validate_config_valid_multi_spec() {
364        let mut config = Config::default();
365        config.specs = vec![
366            crate::config::model::SpecEntry {
367                name: "auth".to_string(),
368                path: "specs/auth.yaml".to_string(),
369                schemas: crate::config::model::SchemasConfig::default(),
370                apis: crate::config::model::ApisConfig::default(),
371                hooks: None,
372                modules: crate::config::model::ModulesConfig::default(),
373            },
374            crate::config::model::SpecEntry {
375                name: "orders".to_string(),
376                path: "specs/orders.json".to_string(),
377                schemas: crate::config::model::SchemasConfig::default(),
378                apis: crate::config::model::ApisConfig::default(),
379                hooks: None,
380                modules: crate::config::model::ModulesConfig::default(),
381            },
382        ];
383
384        let result = validate_config(&config);
385        assert!(result.is_ok());
386    }
387
388    #[test]
389    fn test_validate_config_valid_single_spec() {
390        let mut config = Config::default();
391        config.specs = vec![crate::config::model::SpecEntry {
392            name: "default".to_string(),
393            path: "openapi.json".to_string(),
394            schemas: crate::config::model::SchemasConfig::default(),
395            apis: crate::config::model::ApisConfig::default(),
396            hooks: None,
397            modules: crate::config::model::ModulesConfig::default(),
398        }];
399
400        let result = validate_config(&config);
401        assert!(result.is_ok());
402    }
403}