Skip to main content

cc_audit/
profile.rs

1use crate::error::{AuditError, Result};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5
6/// A named scan profile containing preset configurations
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Profile {
9    pub name: String,
10    pub description: String,
11    #[serde(default)]
12    pub strict: bool,
13    #[serde(default)]
14    pub recursive: bool,
15    #[serde(default)]
16    pub ci: bool,
17    #[serde(default)]
18    pub verbose: bool,
19    #[serde(default)]
20    pub skip_comments: bool,
21    #[serde(default)]
22    pub fix_hint: bool,
23    #[serde(default)]
24    pub no_malware_scan: bool,
25    #[serde(default)]
26    pub deep_scan: bool,
27    #[serde(default)]
28    pub min_confidence: String,
29    #[serde(default)]
30    pub format: Option<String>,
31    #[serde(default)]
32    pub scan_type: Option<String>,
33    #[serde(default)]
34    pub disabled_rules: Vec<String>,
35}
36
37impl Profile {
38    /// Get a built-in profile by name
39    pub fn builtin(name: &str) -> Option<Self> {
40        match name {
41            "default" => Some(Self::default_profile()),
42            "strict" => Some(Self::strict_profile()),
43            "ci" => Some(Self::ci_profile()),
44            "quick" => Some(Self::quick_profile()),
45            _ => None,
46        }
47    }
48
49    fn default_profile() -> Self {
50        Self {
51            name: "default".to_string(),
52            description: "Default balanced scan configuration".to_string(),
53            strict: false,
54            recursive: true,
55            ci: false,
56            verbose: false,
57            skip_comments: false,
58            fix_hint: false,
59            no_malware_scan: false,
60            deep_scan: false,
61            min_confidence: "tentative".to_string(),
62            format: None,
63            scan_type: None,
64            disabled_rules: vec![],
65        }
66    }
67
68    fn strict_profile() -> Self {
69        Self {
70            name: "strict".to_string(),
71            description: "Strict mode - all findings reported, no rules disabled".to_string(),
72            strict: true,
73            recursive: true,
74            ci: false,
75            verbose: true,
76            skip_comments: false,
77            fix_hint: true,
78            no_malware_scan: false,
79            deep_scan: true,
80            min_confidence: "tentative".to_string(),
81            format: None,
82            scan_type: None,
83            disabled_rules: vec![],
84        }
85    }
86
87    fn ci_profile() -> Self {
88        Self {
89            name: "ci".to_string(),
90            description: "CI/CD optimized - non-interactive, JSON output".to_string(),
91            strict: true,
92            recursive: true,
93            ci: true,
94            verbose: false,
95            skip_comments: true,
96            fix_hint: false,
97            no_malware_scan: false,
98            deep_scan: false,
99            min_confidence: "firm".to_string(),
100            format: Some("json".to_string()),
101            scan_type: None,
102            disabled_rules: vec![],
103        }
104    }
105
106    fn quick_profile() -> Self {
107        Self {
108            name: "quick".to_string(),
109            description: "Quick scan - high confidence only, no deep scan".to_string(),
110            strict: false,
111            recursive: true,
112            ci: false,
113            verbose: false,
114            skip_comments: true,
115            fix_hint: false,
116            no_malware_scan: true, // Skip malware scan for speed
117            deep_scan: false,
118            min_confidence: "certain".to_string(),
119            format: None,
120            scan_type: None,
121            disabled_rules: vec![],
122        }
123    }
124
125    /// Load a profile from the profiles directory
126    pub fn load(name: &str) -> Result<Self> {
127        // First try built-in profiles
128        if let Some(profile) = Self::builtin(name) {
129            return Ok(profile);
130        }
131
132        // Then try user profiles
133        let profile_path = Self::get_profile_path(name)?;
134
135        if !profile_path.exists() {
136            return Err(AuditError::FileNotFound(format!(
137                "Profile '{}' not found. Available built-in profiles: default, strict, ci, quick",
138                name
139            )));
140        }
141
142        let content = fs::read_to_string(&profile_path).map_err(|e| AuditError::ReadError {
143            path: profile_path.display().to_string(),
144            source: e,
145        })?;
146
147        serde_yaml::from_str(&content).map_err(|e| AuditError::ParseError {
148            path: profile_path.display().to_string(),
149            message: e.to_string(),
150        })
151    }
152
153    /// Save a profile to the profiles directory
154    pub fn save(&self) -> Result<PathBuf> {
155        let profiles_dir = Self::get_profiles_dir()?;
156        fs::create_dir_all(&profiles_dir).map_err(|e| AuditError::ReadError {
157            path: profiles_dir.display().to_string(),
158            source: e,
159        })?;
160
161        let profile_path = profiles_dir.join(format!("{}.yaml", self.name));
162
163        let content = serde_yaml::to_string(self).map_err(|e| AuditError::ParseError {
164            path: profile_path.display().to_string(),
165            message: e.to_string(),
166        })?;
167
168        fs::write(&profile_path, content).map_err(|e| AuditError::ReadError {
169            path: profile_path.display().to_string(),
170            source: e,
171        })?;
172
173        Ok(profile_path)
174    }
175
176    /// List all available profiles (built-in and user)
177    pub fn list_all() -> Vec<String> {
178        let mut profiles = vec![
179            "default".to_string(),
180            "strict".to_string(),
181            "ci".to_string(),
182            "quick".to_string(),
183        ];
184
185        // Add user profiles
186        if let Ok(dir) = Self::get_profiles_dir()
187            && let Ok(entries) = fs::read_dir(dir)
188        {
189            for entry in entries.flatten() {
190                if let Some(name) = entry.path().file_stem()
191                    && let Some(name_str) = name.to_str()
192                    && !profiles.contains(&name_str.to_string())
193                {
194                    profiles.push(name_str.to_string());
195                }
196            }
197        }
198
199        profiles
200    }
201
202    fn get_profiles_dir() -> Result<PathBuf> {
203        let home = dirs::home_dir().ok_or_else(|| {
204            AuditError::FileNotFound("Could not determine home directory".to_string())
205        })?;
206
207        Ok(home.join(".config").join("cc-audit").join("profiles"))
208    }
209
210    fn get_profile_path(name: &str) -> Result<PathBuf> {
211        let profiles_dir = Self::get_profiles_dir()?;
212        Ok(profiles_dir.join(format!("{}.yaml", name)))
213    }
214
215    /// Apply profile settings to effective config
216    pub fn apply_to_config(&self, config: &mut crate::config::ScanConfig) {
217        config.strict = config.strict || self.strict;
218        config.recursive = config.recursive || self.recursive;
219        config.ci = config.ci || self.ci;
220        config.verbose = config.verbose || self.verbose;
221        config.skip_comments = config.skip_comments || self.skip_comments;
222        config.fix_hint = config.fix_hint || self.fix_hint;
223        config.no_malware_scan = config.no_malware_scan || self.no_malware_scan;
224
225        if !self.min_confidence.is_empty() && config.min_confidence.is_none() {
226            config.min_confidence = Some(self.min_confidence.clone());
227        }
228
229        if let Some(ref format) = self.format
230            && config.format.is_none()
231        {
232            config.format = Some(format.clone());
233        }
234
235        if let Some(ref scan_type) = self.scan_type
236            && config.scan_type.is_none()
237        {
238            config.scan_type = Some(scan_type.clone());
239        }
240    }
241}
242
243impl Default for Profile {
244    fn default() -> Self {
245        Self::default_profile()
246    }
247}
248
249/// Create a profile from CheckArgs settings
250pub fn profile_from_check_args(name: &str, args: &crate::CheckArgs, verbose: bool) -> Profile {
251    Profile {
252        name: name.to_string(),
253        description: "Custom profile saved from CLI settings".to_string(),
254        strict: args.strict,
255        recursive: !args.no_recursive,
256        ci: args.ci,
257        verbose,
258        skip_comments: args.skip_comments,
259        fix_hint: args.fix_hint,
260        no_malware_scan: args.no_malware_scan,
261        deep_scan: args.deep_scan,
262        min_confidence: args
263            .min_confidence
264            .map(|c| format!("{:?}", c).to_lowercase())
265            .unwrap_or_else(|| "tentative".to_string()),
266        format: Some(format!("{:?}", args.format).to_lowercase()),
267        scan_type: Some(format!("{:?}", args.scan_type).to_lowercase()),
268        disabled_rules: vec![],
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::config::ScanConfig;
276
277    #[test]
278    fn test_builtin_profiles() {
279        assert!(Profile::builtin("default").is_some());
280        assert!(Profile::builtin("strict").is_some());
281        assert!(Profile::builtin("ci").is_some());
282        assert!(Profile::builtin("quick").is_some());
283        assert!(Profile::builtin("nonexistent").is_none());
284    }
285
286    #[test]
287    fn test_default_profile() {
288        let profile = Profile::default_profile();
289        assert_eq!(profile.name, "default");
290        assert!(!profile.strict);
291        assert!(profile.recursive);
292    }
293
294    #[test]
295    fn test_strict_profile() {
296        let profile = Profile::strict_profile();
297        assert_eq!(profile.name, "strict");
298        assert!(profile.strict);
299        assert!(profile.verbose);
300        assert!(profile.deep_scan);
301    }
302
303    #[test]
304    fn test_ci_profile() {
305        let profile = Profile::ci_profile();
306        assert_eq!(profile.name, "ci");
307        assert!(profile.ci);
308        assert!(profile.strict);
309        assert_eq!(profile.format, Some("json".to_string()));
310    }
311
312    #[test]
313    fn test_quick_profile() {
314        let profile = Profile::quick_profile();
315        assert_eq!(profile.name, "quick");
316        assert!(profile.no_malware_scan);
317        assert!(!profile.deep_scan);
318        assert_eq!(profile.min_confidence, "certain");
319    }
320
321    #[test]
322    fn test_list_all_includes_builtins() {
323        let profiles = Profile::list_all();
324        assert!(profiles.contains(&"default".to_string()));
325        assert!(profiles.contains(&"strict".to_string()));
326        assert!(profiles.contains(&"ci".to_string()));
327        assert!(profiles.contains(&"quick".to_string()));
328    }
329
330    #[test]
331    fn test_profile_serialize_deserialize() {
332        let profile = Profile::strict_profile();
333        let yaml = serde_yaml::to_string(&profile).unwrap();
334        let parsed: Profile = serde_yaml::from_str(&yaml).unwrap();
335        assert_eq!(profile.name, parsed.name);
336        assert_eq!(profile.strict, parsed.strict);
337    }
338
339    #[test]
340    fn test_default_trait() {
341        let profile = Profile::default();
342        assert_eq!(profile.name, "default");
343    }
344
345    #[test]
346    fn test_load_builtin_profile() {
347        let profile = Profile::load("default").unwrap();
348        assert_eq!(profile.name, "default");
349
350        let profile = Profile::load("strict").unwrap();
351        assert_eq!(profile.name, "strict");
352    }
353
354    #[test]
355    fn test_load_nonexistent_profile() {
356        let result = Profile::load("nonexistent_profile_xyz");
357        assert!(result.is_err());
358    }
359
360    #[test]
361    fn test_apply_to_config_basic() {
362        let profile = Profile::strict_profile();
363        let mut config = ScanConfig::default();
364
365        profile.apply_to_config(&mut config);
366
367        assert!(config.strict);
368        assert!(config.verbose);
369        assert!(config.fix_hint);
370    }
371
372    #[test]
373    fn test_apply_to_config_min_confidence() {
374        let profile = Profile::ci_profile();
375        let mut config = ScanConfig::default();
376
377        profile.apply_to_config(&mut config);
378
379        assert_eq!(config.min_confidence, Some("firm".to_string()));
380    }
381
382    #[test]
383    fn test_apply_to_config_format() {
384        let profile = Profile::ci_profile();
385        let mut config = ScanConfig::default();
386
387        profile.apply_to_config(&mut config);
388
389        assert_eq!(config.format, Some("json".to_string()));
390    }
391
392    #[test]
393    fn test_apply_to_config_does_not_override_existing() {
394        let profile = Profile::ci_profile();
395        let mut config = ScanConfig {
396            format: Some("sarif".to_string()),
397            min_confidence: Some("certain".to_string()),
398            ..Default::default()
399        };
400
401        profile.apply_to_config(&mut config);
402
403        // Existing values should not be overridden
404        assert_eq!(config.format, Some("sarif".to_string()));
405        assert_eq!(config.min_confidence, Some("certain".to_string()));
406    }
407
408    #[test]
409    fn test_apply_to_config_scan_type() {
410        let mut profile = Profile::default_profile();
411        profile.scan_type = Some("hook".to_string());
412        let mut config = ScanConfig::default();
413
414        profile.apply_to_config(&mut config);
415
416        assert_eq!(config.scan_type, Some("hook".to_string()));
417    }
418
419    #[test]
420    fn test_apply_to_config_no_malware_scan() {
421        let profile = Profile::quick_profile();
422        let mut config = ScanConfig::default();
423
424        profile.apply_to_config(&mut config);
425
426        assert!(config.no_malware_scan);
427    }
428
429    #[test]
430    fn test_apply_to_config_empty_min_confidence() {
431        let mut profile = Profile::default_profile();
432        profile.min_confidence = String::new();
433        let mut config = ScanConfig::default();
434
435        profile.apply_to_config(&mut config);
436
437        // Empty min_confidence should not set config
438        assert!(config.min_confidence.is_none());
439    }
440
441    #[test]
442    fn test_get_profiles_dir() {
443        let result = Profile::get_profiles_dir();
444        assert!(result.is_ok());
445        let path = result.unwrap();
446        assert!(path.ends_with("profiles"));
447    }
448
449    #[test]
450    fn test_get_profile_path() {
451        let result = Profile::get_profile_path("test_profile");
452        assert!(result.is_ok());
453        let path = result.unwrap();
454        assert!(path.ends_with("test_profile.yaml"));
455    }
456
457    #[test]
458    fn test_profile_debug_trait() {
459        let profile = Profile::default();
460        let debug_str = format!("{:?}", profile);
461        assert!(debug_str.contains("Profile"));
462        assert!(debug_str.contains("default"));
463    }
464
465    #[test]
466    fn test_profile_clone_trait() {
467        let profile = Profile::strict_profile();
468        let cloned = profile.clone();
469        assert_eq!(profile.name, cloned.name);
470        assert_eq!(profile.strict, cloned.strict);
471    }
472
473    #[test]
474    fn test_profile_from_check_args() {
475        use crate::CheckArgs;
476
477        let args = CheckArgs {
478            strict: true,
479            ..Default::default()
480        };
481        let profile = profile_from_check_args("test_profile", &args, true);
482
483        assert_eq!(profile.name, "test_profile");
484        assert!(profile.strict);
485        assert!(profile.verbose);
486        assert!(profile.description.contains("Custom profile"));
487    }
488
489    #[test]
490    fn test_profile_from_check_args_with_options() {
491        use crate::CheckArgs;
492
493        let args = CheckArgs {
494            skip_comments: true,
495            fix_hint: true,
496            no_malware_scan: true,
497            deep_scan: true,
498            ..Default::default()
499        };
500        let profile = profile_from_check_args("custom", &args, false);
501
502        assert!(profile.skip_comments);
503        assert!(profile.fix_hint);
504        assert!(profile.no_malware_scan);
505        assert!(profile.deep_scan);
506    }
507
508    #[test]
509    fn test_profile_from_check_args_format_and_type() {
510        use crate::{CheckArgs, OutputFormat, ScanType};
511
512        let args = CheckArgs {
513            format: OutputFormat::Json,
514            scan_type: ScanType::Hook,
515            ..Default::default()
516        };
517        let profile = profile_from_check_args("json_profile", &args, false);
518
519        assert!(profile.format.is_some());
520        assert!(profile.scan_type.is_some());
521    }
522
523    #[test]
524    fn test_profile_save_and_load() {
525        // This test creates a temp profile and verifies it can be saved and loaded
526        // Note: This writes to the user's config directory
527        let profile = Profile {
528            name: "test_save_load_unique_12345".to_string(),
529            description: "Test profile for save/load".to_string(),
530            strict: true,
531            recursive: true,
532            ci: false,
533            verbose: true,
534            skip_comments: false,
535            fix_hint: true,
536            no_malware_scan: false,
537            deep_scan: true,
538            min_confidence: "firm".to_string(),
539            format: Some("json".to_string()),
540            scan_type: Some("hook".to_string()),
541            disabled_rules: vec!["PE-001".to_string()],
542        };
543
544        // Save the profile
545        let save_result = profile.save();
546        assert!(save_result.is_ok());
547        let saved_path = save_result.unwrap();
548        assert!(saved_path.exists());
549
550        // Load the profile back
551        let loaded = Profile::load("test_save_load_unique_12345");
552        assert!(loaded.is_ok());
553        let loaded_profile = loaded.unwrap();
554        assert_eq!(loaded_profile.name, "test_save_load_unique_12345");
555        assert!(loaded_profile.strict);
556        assert!(loaded_profile.deep_scan);
557        assert_eq!(loaded_profile.format, Some("json".to_string()));
558
559        // Clean up
560        let _ = fs::remove_file(saved_path);
561    }
562
563    #[test]
564    fn test_apply_to_config_recursive() {
565        let profile = Profile::default_profile();
566        let mut config = ScanConfig {
567            recursive: false,
568            ..Default::default()
569        };
570
571        profile.apply_to_config(&mut config);
572
573        // Profile has recursive=true, so it should be true after apply
574        assert!(config.recursive);
575    }
576
577    #[test]
578    fn test_apply_to_config_ci() {
579        let profile = Profile::ci_profile();
580        let mut config = ScanConfig::default();
581
582        profile.apply_to_config(&mut config);
583
584        assert!(config.ci);
585    }
586
587    #[test]
588    fn test_apply_to_config_skip_comments() {
589        let profile = Profile::quick_profile();
590        let mut config = ScanConfig::default();
591
592        profile.apply_to_config(&mut config);
593
594        assert!(config.skip_comments);
595    }
596
597    #[test]
598    fn test_profile_disabled_rules() {
599        let profile = Profile {
600            name: "test".to_string(),
601            description: "Test".to_string(),
602            strict: false,
603            recursive: true,
604            ci: false,
605            verbose: false,
606            skip_comments: false,
607            fix_hint: false,
608            no_malware_scan: false,
609            deep_scan: false,
610            min_confidence: "tentative".to_string(),
611            format: None,
612            scan_type: None,
613            disabled_rules: vec!["PE-001".to_string(), "SC-001".to_string()],
614        };
615
616        assert_eq!(profile.disabled_rules.len(), 2);
617        assert!(profile.disabled_rules.contains(&"PE-001".to_string()));
618    }
619
620    #[test]
621    fn test_profile_from_check_args_ci_mode() {
622        use crate::CheckArgs;
623
624        let args = CheckArgs {
625            ci: true,
626            ..Default::default()
627        };
628        let profile = profile_from_check_args("ci_profile", &args, false);
629
630        assert!(profile.ci);
631        assert!(profile.recursive); // default is recursive (no_recursive=false)
632    }
633
634    #[test]
635    fn test_profile_from_check_args_recursive() {
636        use crate::CheckArgs;
637
638        let args = CheckArgs {
639            no_recursive: false, // recursive enabled
640            ..Default::default()
641        };
642        let profile = profile_from_check_args("recursive_profile", &args, false);
643
644        assert!(profile.recursive);
645    }
646
647    #[test]
648    fn test_profile_from_check_args_no_recursive() {
649        use crate::CheckArgs;
650
651        let args = CheckArgs {
652            no_recursive: true, // recursive disabled
653            ..Default::default()
654        };
655        let profile = profile_from_check_args("non_recursive_profile", &args, false);
656
657        assert!(!profile.recursive);
658    }
659
660    #[test]
661    fn test_list_all_includes_user_profiles() {
662        // Save a user profile
663        let profile = Profile {
664            name: "test_user_profile_list_all".to_string(),
665            description: "Test user profile".to_string(),
666            strict: false,
667            recursive: true,
668            ci: false,
669            verbose: false,
670            skip_comments: false,
671            fix_hint: false,
672            no_malware_scan: false,
673            deep_scan: false,
674            min_confidence: "tentative".to_string(),
675            format: None,
676            scan_type: None,
677            disabled_rules: vec![],
678        };
679
680        let save_result = profile.save();
681        assert!(save_result.is_ok());
682        let saved_path = save_result.unwrap();
683
684        // list_all should include the user profile
685        let profiles = Profile::list_all();
686        assert!(profiles.contains(&"test_user_profile_list_all".to_string()));
687
688        // Clean up
689        let _ = fs::remove_file(saved_path);
690    }
691
692    #[test]
693    fn test_load_user_profile_from_file() {
694        // Save a user profile
695        let profile = Profile {
696            name: "test_load_user_profile".to_string(),
697            description: "Test for loading".to_string(),
698            strict: true,
699            recursive: false,
700            ci: true,
701            verbose: true,
702            skip_comments: true,
703            fix_hint: true,
704            no_malware_scan: true,
705            deep_scan: true,
706            min_confidence: "certain".to_string(),
707            format: Some("sarif".to_string()),
708            scan_type: Some("docker".to_string()),
709            disabled_rules: vec!["PE-001".to_string()],
710        };
711
712        let save_result = profile.save();
713        assert!(save_result.is_ok());
714        let saved_path = save_result.unwrap();
715
716        // Load the profile back
717        let loaded = Profile::load("test_load_user_profile");
718        assert!(loaded.is_ok());
719        let loaded_profile = loaded.unwrap();
720
721        assert_eq!(loaded_profile.name, "test_load_user_profile");
722        assert!(loaded_profile.strict);
723        assert!(!loaded_profile.recursive);
724        assert!(loaded_profile.ci);
725        assert!(loaded_profile.verbose);
726        assert!(loaded_profile.skip_comments);
727        assert!(loaded_profile.fix_hint);
728        assert!(loaded_profile.no_malware_scan);
729        assert!(loaded_profile.deep_scan);
730        assert_eq!(loaded_profile.min_confidence, "certain");
731        assert_eq!(loaded_profile.format, Some("sarif".to_string()));
732        assert_eq!(loaded_profile.scan_type, Some("docker".to_string()));
733        assert_eq!(loaded_profile.disabled_rules, vec!["PE-001".to_string()]);
734
735        // Clean up
736        let _ = fs::remove_file(saved_path);
737    }
738
739    #[test]
740    fn test_apply_to_config_with_none_format() {
741        let mut profile = Profile::default_profile();
742        profile.format = None;
743        let config = ScanConfig {
744            format: None,
745            ..Default::default()
746        };
747        let mut config = config;
748
749        profile.apply_to_config(&mut config);
750
751        // Format should remain None
752        assert!(config.format.is_none());
753    }
754
755    #[test]
756    fn test_apply_to_config_with_none_scan_type() {
757        let mut profile = Profile::default_profile();
758        profile.scan_type = None;
759        let config = ScanConfig {
760            scan_type: None,
761            ..Default::default()
762        };
763        let mut config = config;
764
765        profile.apply_to_config(&mut config);
766
767        // scan_type should remain None
768        assert!(config.scan_type.is_none());
769    }
770
771    #[test]
772    fn test_profile_with_all_fields() {
773        let profile = Profile {
774            name: "complete".to_string(),
775            description: "Complete profile with all fields".to_string(),
776            strict: true,
777            recursive: true,
778            ci: true,
779            verbose: true,
780            skip_comments: true,
781            fix_hint: true,
782            no_malware_scan: true,
783            deep_scan: true,
784            min_confidence: "firm".to_string(),
785            format: Some("html".to_string()),
786            scan_type: Some("mcp".to_string()),
787            disabled_rules: vec!["PE-001".to_string(), "SC-001".to_string()],
788        };
789
790        // Verify all fields
791        assert_eq!(profile.name, "complete");
792        assert!(profile.strict);
793        assert!(profile.recursive);
794        assert!(profile.ci);
795        assert!(profile.verbose);
796        assert!(profile.skip_comments);
797        assert!(profile.fix_hint);
798        assert!(profile.no_malware_scan);
799        assert!(profile.deep_scan);
800        assert_eq!(profile.min_confidence, "firm");
801        assert_eq!(profile.format, Some("html".to_string()));
802        assert_eq!(profile.scan_type, Some("mcp".to_string()));
803        assert_eq!(profile.disabled_rules.len(), 2);
804    }
805}