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 current CLI settings
250pub fn profile_from_cli(name: &str, cli: &crate::Cli) -> Profile {
251    Profile {
252        name: name.to_string(),
253        description: "Custom profile saved from CLI settings".to_string(),
254        strict: cli.strict,
255        recursive: cli.recursive,
256        ci: cli.ci,
257        verbose: cli.verbose,
258        skip_comments: cli.skip_comments,
259        fix_hint: cli.fix_hint,
260        no_malware_scan: cli.no_malware_scan,
261        deep_scan: cli.deep_scan,
262        min_confidence: format!("{:?}", cli.min_confidence).to_lowercase(),
263        format: Some(format!("{:?}", cli.format).to_lowercase()),
264        scan_type: Some(format!("{:?}", cli.scan_type).to_lowercase()),
265        disabled_rules: vec![],
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::config::ScanConfig;
273
274    #[test]
275    fn test_builtin_profiles() {
276        assert!(Profile::builtin("default").is_some());
277        assert!(Profile::builtin("strict").is_some());
278        assert!(Profile::builtin("ci").is_some());
279        assert!(Profile::builtin("quick").is_some());
280        assert!(Profile::builtin("nonexistent").is_none());
281    }
282
283    #[test]
284    fn test_default_profile() {
285        let profile = Profile::default_profile();
286        assert_eq!(profile.name, "default");
287        assert!(!profile.strict);
288        assert!(profile.recursive);
289    }
290
291    #[test]
292    fn test_strict_profile() {
293        let profile = Profile::strict_profile();
294        assert_eq!(profile.name, "strict");
295        assert!(profile.strict);
296        assert!(profile.verbose);
297        assert!(profile.deep_scan);
298    }
299
300    #[test]
301    fn test_ci_profile() {
302        let profile = Profile::ci_profile();
303        assert_eq!(profile.name, "ci");
304        assert!(profile.ci);
305        assert!(profile.strict);
306        assert_eq!(profile.format, Some("json".to_string()));
307    }
308
309    #[test]
310    fn test_quick_profile() {
311        let profile = Profile::quick_profile();
312        assert_eq!(profile.name, "quick");
313        assert!(profile.no_malware_scan);
314        assert!(!profile.deep_scan);
315        assert_eq!(profile.min_confidence, "certain");
316    }
317
318    #[test]
319    fn test_list_all_includes_builtins() {
320        let profiles = Profile::list_all();
321        assert!(profiles.contains(&"default".to_string()));
322        assert!(profiles.contains(&"strict".to_string()));
323        assert!(profiles.contains(&"ci".to_string()));
324        assert!(profiles.contains(&"quick".to_string()));
325    }
326
327    #[test]
328    fn test_profile_serialize_deserialize() {
329        let profile = Profile::strict_profile();
330        let yaml = serde_yaml::to_string(&profile).unwrap();
331        let parsed: Profile = serde_yaml::from_str(&yaml).unwrap();
332        assert_eq!(profile.name, parsed.name);
333        assert_eq!(profile.strict, parsed.strict);
334    }
335
336    #[test]
337    fn test_default_trait() {
338        let profile = Profile::default();
339        assert_eq!(profile.name, "default");
340    }
341
342    #[test]
343    fn test_load_builtin_profile() {
344        let profile = Profile::load("default").unwrap();
345        assert_eq!(profile.name, "default");
346
347        let profile = Profile::load("strict").unwrap();
348        assert_eq!(profile.name, "strict");
349    }
350
351    #[test]
352    fn test_load_nonexistent_profile() {
353        let result = Profile::load("nonexistent_profile_xyz");
354        assert!(result.is_err());
355    }
356
357    #[test]
358    fn test_apply_to_config_basic() {
359        let profile = Profile::strict_profile();
360        let mut config = ScanConfig::default();
361
362        profile.apply_to_config(&mut config);
363
364        assert!(config.strict);
365        assert!(config.verbose);
366        assert!(config.fix_hint);
367    }
368
369    #[test]
370    fn test_apply_to_config_min_confidence() {
371        let profile = Profile::ci_profile();
372        let mut config = ScanConfig::default();
373
374        profile.apply_to_config(&mut config);
375
376        assert_eq!(config.min_confidence, Some("firm".to_string()));
377    }
378
379    #[test]
380    fn test_apply_to_config_format() {
381        let profile = Profile::ci_profile();
382        let mut config = ScanConfig::default();
383
384        profile.apply_to_config(&mut config);
385
386        assert_eq!(config.format, Some("json".to_string()));
387    }
388
389    #[test]
390    fn test_apply_to_config_does_not_override_existing() {
391        let profile = Profile::ci_profile();
392        let mut config = ScanConfig {
393            format: Some("sarif".to_string()),
394            min_confidence: Some("certain".to_string()),
395            ..Default::default()
396        };
397
398        profile.apply_to_config(&mut config);
399
400        // Existing values should not be overridden
401        assert_eq!(config.format, Some("sarif".to_string()));
402        assert_eq!(config.min_confidence, Some("certain".to_string()));
403    }
404
405    #[test]
406    fn test_apply_to_config_scan_type() {
407        let mut profile = Profile::default_profile();
408        profile.scan_type = Some("hook".to_string());
409        let mut config = ScanConfig::default();
410
411        profile.apply_to_config(&mut config);
412
413        assert_eq!(config.scan_type, Some("hook".to_string()));
414    }
415
416    #[test]
417    fn test_apply_to_config_no_malware_scan() {
418        let profile = Profile::quick_profile();
419        let mut config = ScanConfig::default();
420
421        profile.apply_to_config(&mut config);
422
423        assert!(config.no_malware_scan);
424    }
425
426    #[test]
427    fn test_apply_to_config_empty_min_confidence() {
428        let mut profile = Profile::default_profile();
429        profile.min_confidence = String::new();
430        let mut config = ScanConfig::default();
431
432        profile.apply_to_config(&mut config);
433
434        // Empty min_confidence should not set config
435        assert!(config.min_confidence.is_none());
436    }
437
438    #[test]
439    fn test_get_profiles_dir() {
440        let result = Profile::get_profiles_dir();
441        assert!(result.is_ok());
442        let path = result.unwrap();
443        assert!(path.ends_with("profiles"));
444    }
445
446    #[test]
447    fn test_get_profile_path() {
448        let result = Profile::get_profile_path("test_profile");
449        assert!(result.is_ok());
450        let path = result.unwrap();
451        assert!(path.ends_with("test_profile.yaml"));
452    }
453
454    #[test]
455    fn test_profile_debug_trait() {
456        let profile = Profile::default();
457        let debug_str = format!("{:?}", profile);
458        assert!(debug_str.contains("Profile"));
459        assert!(debug_str.contains("default"));
460    }
461
462    #[test]
463    fn test_profile_clone_trait() {
464        let profile = Profile::strict_profile();
465        let cloned = profile.clone();
466        assert_eq!(profile.name, cloned.name);
467        assert_eq!(profile.strict, cloned.strict);
468    }
469
470    #[test]
471    fn test_profile_from_cli() {
472        use crate::Cli;
473        use clap::Parser;
474
475        let cli = Cli::parse_from(["cc-audit", "--strict", "--verbose", "."]);
476        let profile = profile_from_cli("test_profile", &cli);
477
478        assert_eq!(profile.name, "test_profile");
479        assert!(profile.strict);
480        assert!(profile.verbose);
481        assert!(profile.description.contains("Custom profile"));
482    }
483
484    #[test]
485    fn test_profile_from_cli_with_options() {
486        use crate::Cli;
487        use clap::Parser;
488
489        let cli = Cli::parse_from([
490            "cc-audit",
491            "--skip-comments",
492            "--fix-hint",
493            "--no-malware-scan",
494            "--deep-scan",
495            ".",
496        ]);
497        let profile = profile_from_cli("custom", &cli);
498
499        assert!(profile.skip_comments);
500        assert!(profile.fix_hint);
501        assert!(profile.no_malware_scan);
502        assert!(profile.deep_scan);
503    }
504
505    #[test]
506    fn test_profile_from_cli_format_and_type() {
507        use crate::Cli;
508        use clap::Parser;
509
510        let cli = Cli::parse_from(["cc-audit", "--format", "json", "--type", "hook", "."]);
511        let profile = profile_from_cli("json_profile", &cli);
512
513        assert!(profile.format.is_some());
514        assert!(profile.scan_type.is_some());
515    }
516
517    #[test]
518    fn test_profile_save_and_load() {
519        // This test creates a temp profile and verifies it can be saved and loaded
520        // Note: This writes to the user's config directory
521        let profile = Profile {
522            name: "test_save_load_unique_12345".to_string(),
523            description: "Test profile for save/load".to_string(),
524            strict: true,
525            recursive: true,
526            ci: false,
527            verbose: true,
528            skip_comments: false,
529            fix_hint: true,
530            no_malware_scan: false,
531            deep_scan: true,
532            min_confidence: "firm".to_string(),
533            format: Some("json".to_string()),
534            scan_type: Some("hook".to_string()),
535            disabled_rules: vec!["PE-001".to_string()],
536        };
537
538        // Save the profile
539        let save_result = profile.save();
540        assert!(save_result.is_ok());
541        let saved_path = save_result.unwrap();
542        assert!(saved_path.exists());
543
544        // Load the profile back
545        let loaded = Profile::load("test_save_load_unique_12345");
546        assert!(loaded.is_ok());
547        let loaded_profile = loaded.unwrap();
548        assert_eq!(loaded_profile.name, "test_save_load_unique_12345");
549        assert!(loaded_profile.strict);
550        assert!(loaded_profile.deep_scan);
551        assert_eq!(loaded_profile.format, Some("json".to_string()));
552
553        // Clean up
554        let _ = fs::remove_file(saved_path);
555    }
556
557    #[test]
558    fn test_apply_to_config_recursive() {
559        let profile = Profile::default_profile();
560        let mut config = ScanConfig {
561            recursive: false,
562            ..Default::default()
563        };
564
565        profile.apply_to_config(&mut config);
566
567        // Profile has recursive=true, so it should be true after apply
568        assert!(config.recursive);
569    }
570
571    #[test]
572    fn test_apply_to_config_ci() {
573        let profile = Profile::ci_profile();
574        let mut config = ScanConfig::default();
575
576        profile.apply_to_config(&mut config);
577
578        assert!(config.ci);
579    }
580
581    #[test]
582    fn test_apply_to_config_skip_comments() {
583        let profile = Profile::quick_profile();
584        let mut config = ScanConfig::default();
585
586        profile.apply_to_config(&mut config);
587
588        assert!(config.skip_comments);
589    }
590
591    #[test]
592    fn test_profile_disabled_rules() {
593        let profile = Profile {
594            name: "test".to_string(),
595            description: "Test".to_string(),
596            strict: false,
597            recursive: true,
598            ci: false,
599            verbose: false,
600            skip_comments: false,
601            fix_hint: false,
602            no_malware_scan: false,
603            deep_scan: false,
604            min_confidence: "tentative".to_string(),
605            format: None,
606            scan_type: None,
607            disabled_rules: vec!["PE-001".to_string(), "SC-001".to_string()],
608        };
609
610        assert_eq!(profile.disabled_rules.len(), 2);
611        assert!(profile.disabled_rules.contains(&"PE-001".to_string()));
612    }
613
614    #[test]
615    fn test_profile_from_cli_ci_mode() {
616        use crate::Cli;
617        use clap::Parser;
618
619        let cli = Cli::parse_from(["cc-audit", "--ci", "."]);
620        let profile = profile_from_cli("ci_profile", &cli);
621
622        assert!(profile.ci);
623        assert!(!profile.recursive); // default
624    }
625
626    #[test]
627    fn test_profile_from_cli_recursive() {
628        use crate::Cli;
629        use clap::Parser;
630
631        let cli = Cli::parse_from(["cc-audit", "--recursive", "."]);
632        let profile = profile_from_cli("recursive_profile", &cli);
633
634        assert!(profile.recursive);
635    }
636
637    #[test]
638    fn test_list_all_includes_user_profiles() {
639        // Save a user profile
640        let profile = Profile {
641            name: "test_user_profile_list_all".to_string(),
642            description: "Test user profile".to_string(),
643            strict: false,
644            recursive: true,
645            ci: false,
646            verbose: false,
647            skip_comments: false,
648            fix_hint: false,
649            no_malware_scan: false,
650            deep_scan: false,
651            min_confidence: "tentative".to_string(),
652            format: None,
653            scan_type: None,
654            disabled_rules: vec![],
655        };
656
657        let save_result = profile.save();
658        assert!(save_result.is_ok());
659        let saved_path = save_result.unwrap();
660
661        // list_all should include the user profile
662        let profiles = Profile::list_all();
663        assert!(profiles.contains(&"test_user_profile_list_all".to_string()));
664
665        // Clean up
666        let _ = fs::remove_file(saved_path);
667    }
668
669    #[test]
670    fn test_load_user_profile_from_file() {
671        // Save a user profile
672        let profile = Profile {
673            name: "test_load_user_profile".to_string(),
674            description: "Test for loading".to_string(),
675            strict: true,
676            recursive: false,
677            ci: true,
678            verbose: true,
679            skip_comments: true,
680            fix_hint: true,
681            no_malware_scan: true,
682            deep_scan: true,
683            min_confidence: "certain".to_string(),
684            format: Some("sarif".to_string()),
685            scan_type: Some("docker".to_string()),
686            disabled_rules: vec!["PE-001".to_string()],
687        };
688
689        let save_result = profile.save();
690        assert!(save_result.is_ok());
691        let saved_path = save_result.unwrap();
692
693        // Load the profile back
694        let loaded = Profile::load("test_load_user_profile");
695        assert!(loaded.is_ok());
696        let loaded_profile = loaded.unwrap();
697
698        assert_eq!(loaded_profile.name, "test_load_user_profile");
699        assert!(loaded_profile.strict);
700        assert!(!loaded_profile.recursive);
701        assert!(loaded_profile.ci);
702        assert!(loaded_profile.verbose);
703        assert!(loaded_profile.skip_comments);
704        assert!(loaded_profile.fix_hint);
705        assert!(loaded_profile.no_malware_scan);
706        assert!(loaded_profile.deep_scan);
707        assert_eq!(loaded_profile.min_confidence, "certain");
708        assert_eq!(loaded_profile.format, Some("sarif".to_string()));
709        assert_eq!(loaded_profile.scan_type, Some("docker".to_string()));
710        assert_eq!(loaded_profile.disabled_rules, vec!["PE-001".to_string()]);
711
712        // Clean up
713        let _ = fs::remove_file(saved_path);
714    }
715
716    #[test]
717    fn test_apply_to_config_with_none_format() {
718        let mut profile = Profile::default_profile();
719        profile.format = None;
720        let config = ScanConfig {
721            format: None,
722            ..Default::default()
723        };
724        let mut config = config;
725
726        profile.apply_to_config(&mut config);
727
728        // Format should remain None
729        assert!(config.format.is_none());
730    }
731
732    #[test]
733    fn test_apply_to_config_with_none_scan_type() {
734        let mut profile = Profile::default_profile();
735        profile.scan_type = None;
736        let config = ScanConfig {
737            scan_type: None,
738            ..Default::default()
739        };
740        let mut config = config;
741
742        profile.apply_to_config(&mut config);
743
744        // scan_type should remain None
745        assert!(config.scan_type.is_none());
746    }
747
748    #[test]
749    fn test_profile_with_all_fields() {
750        let profile = Profile {
751            name: "complete".to_string(),
752            description: "Complete profile with all fields".to_string(),
753            strict: true,
754            recursive: true,
755            ci: true,
756            verbose: true,
757            skip_comments: true,
758            fix_hint: true,
759            no_malware_scan: true,
760            deep_scan: true,
761            min_confidence: "firm".to_string(),
762            format: Some("html".to_string()),
763            scan_type: Some("mcp".to_string()),
764            disabled_rules: vec!["PE-001".to_string(), "SC-001".to_string()],
765        };
766
767        // Verify all fields
768        assert_eq!(profile.name, "complete");
769        assert!(profile.strict);
770        assert!(profile.recursive);
771        assert!(profile.ci);
772        assert!(profile.verbose);
773        assert!(profile.skip_comments);
774        assert!(profile.fix_hint);
775        assert!(profile.no_malware_scan);
776        assert!(profile.deep_scan);
777        assert_eq!(profile.min_confidence, "firm");
778        assert_eq!(profile.format, Some("html".to_string()));
779        assert_eq!(profile.scan_type, Some("mcp".to_string()));
780        assert_eq!(profile.disabled_rules.len(), 2);
781    }
782}