Skip to main content

jsdet_chrome_ext/
profile.rs

1//! Analysis profiles — TOML-based source/sink/transform/sanitizer definitions.
2//!
3//! Researchers define what to look for without writing Rust:
4//!
5//! ```toml
6//! [[sources]]
7//! api = "chrome.runtime.onMessage"
8//! taint_id = 1
9//!
10//! [[sinks]]
11//! api = "chrome.tabs.executeScript"
12//! dangerous_arg = 1
13//! severity = "critical"
14//! cwe = "CWE-94"
15//! ```
16
17use serde::{Deserialize, Serialize};
18
19/// A complete analysis profile loaded from TOML.
20#[derive(Clone, Debug, Default, Serialize, Deserialize)]
21pub struct AnalysisProfile {
22    /// Profile metadata.
23    #[serde(default)]
24    pub profile: ProfileMeta,
25
26    /// Taint sources — where untrusted data enters.
27    #[serde(default)]
28    pub sources: Vec<TaintSource>,
29
30    /// Dangerous sinks — where tainted data must not reach.
31    #[serde(default)]
32    pub sinks: Vec<TaintSink>,
33
34    /// Transforms — APIs that propagate (or don't propagate) taint.
35    #[serde(default)]
36    pub transforms: Vec<TaintTransform>,
37
38    /// Sanitizers — APIs that kill specific taint categories.
39    #[serde(default)]
40    pub sanitizers: Vec<TaintSanitizer>,
41}
42
43/// Profile metadata.
44#[derive(Clone, Debug, Default, Serialize, Deserialize)]
45pub struct ProfileMeta {
46    pub name: String,
47    #[serde(default)]
48    pub description: String,
49    #[serde(default)]
50    pub version: String,
51    #[serde(default)]
52    pub author: String,
53}
54
55/// A taint source — where untrusted data enters the extension.
56#[derive(Clone, Debug, Serialize, Deserialize)]
57pub struct TaintSource {
58    /// Fully qualified API name (e.g. "chrome.runtime.onMessage").
59    pub api: String,
60
61    /// Unique taint ID for tracking through execution.
62    pub taint_id: u32,
63
64    /// Which part of the API result is tainted (e.g. "args[0]", "return").
65    #[serde(default = "default_extract")]
66    pub extract: String,
67
68    /// Human-readable description.
69    #[serde(default)]
70    pub description: String,
71}
72
73/// A dangerous sink — tainted data reaching here is a vulnerability.
74#[derive(Clone, Debug, Serialize, Deserialize)]
75pub struct TaintSink {
76    /// Fully qualified API name.
77    pub api: String,
78
79    /// Which argument is dangerous (0-indexed).
80    #[serde(default)]
81    pub dangerous_arg: u32,
82
83    /// Severity: "critical", "high", "medium", "low", "info".
84    #[serde(default = "default_severity")]
85    pub severity: String,
86
87    /// CWE identifier.
88    #[serde(default)]
89    pub cwe: String,
90
91    /// Human-readable description.
92    #[serde(default)]
93    pub description: String,
94}
95
96/// A transform — an API that propagates or blocks taint.
97#[derive(Clone, Debug, Serialize, Deserialize)]
98pub struct TaintTransform {
99    /// API name.
100    pub api: String,
101
102    /// Whether taint passes through this transform.
103    #[serde(default = "default_true")]
104    pub propagates_taint: bool,
105}
106
107/// A sanitizer — an API that kills taint for specific categories.
108#[derive(Clone, Debug, Serialize, Deserialize)]
109pub struct TaintSanitizer {
110    /// API name.
111    pub api: String,
112
113    /// Which taint categories this sanitizer kills.
114    #[serde(default)]
115    pub kills_taint: Vec<String>,
116}
117
118fn default_extract() -> String {
119    "args[0]".into()
120}
121fn default_severity() -> String {
122    "high".into()
123}
124fn default_true() -> bool {
125    true
126}
127
128impl AnalysisProfile {
129    /// Parse a TOML profile string.
130    pub fn parse(toml_str: &str) -> Result<Self, String> {
131        toml::from_str(toml_str).map_err(|e| format!("profile parse error: {e}"))
132    }
133
134    /// Load and merge multiple profiles. Validates taint ID uniqueness.
135    pub fn merge(profiles: Vec<AnalysisProfile>) -> Result<Self, String> {
136        let mut merged = AnalysisProfile::default();
137        merged.profile.name = "merged".into();
138
139        let mut seen_taint_ids = std::collections::HashSet::new();
140
141        for profile in profiles {
142            for source in &profile.sources {
143                if !seen_taint_ids.insert(source.taint_id) {
144                    return Err(format!(
145                        "duplicate taint_id {} in source '{}' from profile '{}'",
146                        source.taint_id, source.api, profile.profile.name,
147                    ));
148                }
149            }
150
151            merged.sources.extend(profile.sources);
152            merged.sinks.extend(profile.sinks);
153            merged.transforms.extend(profile.transforms);
154            merged.sanitizers.extend(profile.sanitizers);
155        }
156
157        Ok(merged)
158    }
159
160    /// Check if an API name is a declared sink.
161    pub fn is_sink(&self, api: &str) -> Option<&TaintSink> {
162        self.sinks.iter().find(|s| s.api == api)
163    }
164
165    /// Check if an API name is a declared source.
166    pub fn is_source(&self, api: &str) -> Option<&TaintSource> {
167        self.sources.iter().find(|s| s.api == api)
168    }
169
170    /// Check if an API kills taint for a given category.
171    pub fn kills_taint(&self, api: &str, category: &str) -> bool {
172        self.sanitizers
173            .iter()
174            .any(|s| s.api == api && s.kills_taint.iter().any(|c| c == category))
175    }
176
177    /// Total number of rules across all categories.
178    pub fn rule_count(&self) -> usize {
179        self.sources.len() + self.sinks.len() + self.transforms.len() + self.sanitizers.len()
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    const SAMPLE_PROFILE: &str = r#"
188[profile]
189name = "test-xss"
190description = "Test XSS detection"
191
192[[sources]]
193api = "chrome.runtime.onMessage"
194taint_id = 1
195extract = "args[0]"
196
197[[sources]]
198api = "window.postMessage"
199taint_id = 2
200extract = "event.data"
201
202[[sinks]]
203api = "chrome.tabs.executeScript"
204dangerous_arg = 1
205severity = "critical"
206cwe = "CWE-94"
207
208[[sinks]]
209api = "eval"
210dangerous_arg = 0
211severity = "critical"
212cwe = "CWE-95"
213
214[[sinks]]
215api = "element.innerHTML"
216dangerous_arg = 0
217severity = "high"
218cwe = "CWE-79"
219
220[[transforms]]
221api = "JSON.parse"
222propagates_taint = true
223
224[[sanitizers]]
225api = "DOMPurify.sanitize"
226kills_taint = ["xss"]
227"#;
228
229    // ============================================================
230    // BASIC PROFILE PARSING TESTS
231    // ============================================================
232
233    #[test]
234    fn parse_profile() {
235        let profile = AnalysisProfile::parse(SAMPLE_PROFILE).unwrap();
236        assert_eq!(profile.profile.name, "test-xss");
237        assert_eq!(profile.profile.description, "Test XSS detection");
238        assert_eq!(profile.sources.len(), 2);
239        assert_eq!(profile.sinks.len(), 3);
240        assert_eq!(profile.transforms.len(), 1);
241        assert_eq!(profile.sanitizers.len(), 1);
242    }
243
244    #[test]
245    fn is_sink_lookup() {
246        let profile = AnalysisProfile::parse(SAMPLE_PROFILE).unwrap();
247        let sink = profile.is_sink("eval").unwrap();
248        assert_eq!(sink.cwe, "CWE-95");
249        assert_eq!(sink.severity, "critical");
250        assert!(profile.is_sink("chrome.tabs.query").is_none());
251    }
252
253    #[test]
254    fn is_source_lookup() {
255        let profile = AnalysisProfile::parse(SAMPLE_PROFILE).unwrap();
256        let source = profile.is_source("chrome.runtime.onMessage").unwrap();
257        assert_eq!(source.taint_id, 1);
258        assert!(profile.is_source("chrome.storage.get").is_none());
259    }
260
261    #[test]
262    fn kills_taint() {
263        let profile = AnalysisProfile::parse(SAMPLE_PROFILE).unwrap();
264        assert!(profile.kills_taint("DOMPurify.sanitize", "xss"));
265        assert!(!profile.kills_taint("DOMPurify.sanitize", "sqli"));
266        assert!(!profile.kills_taint("JSON.parse", "xss"));
267    }
268
269    #[test]
270    fn merge_profiles() {
271        let p1 = AnalysisProfile::parse(
272            r#"
273[profile]
274name = "p1"
275[[sources]]
276api = "source1"
277taint_id = 1
278[[sinks]]
279api = "sink1"
280"#,
281        )
282        .unwrap();
283
284        let p2 = AnalysisProfile::parse(
285            r#"
286[profile]
287name = "p2"
288[[sources]]
289api = "source2"
290taint_id = 2
291[[sinks]]
292api = "sink2"
293"#,
294        )
295        .unwrap();
296
297        let merged = AnalysisProfile::merge(vec![p1, p2]).unwrap();
298        assert_eq!(merged.sources.len(), 2);
299        assert_eq!(merged.sinks.len(), 2);
300    }
301
302    #[test]
303    fn merge_rejects_duplicate_taint_ids() {
304        let p1 = AnalysisProfile::parse(
305            r#"
306[profile]
307name = "p1"
308[[sources]]
309api = "source1"
310taint_id = 1
311"#,
312        )
313        .unwrap();
314
315        let p2 = AnalysisProfile::parse(
316            r#"
317[profile]
318name = "p2"
319[[sources]]
320api = "source2"
321taint_id = 1
322"#,
323        )
324        .unwrap();
325
326        assert!(AnalysisProfile::merge(vec![p1, p2]).is_err());
327    }
328
329    #[test]
330    fn rule_count() {
331        let profile = AnalysisProfile::parse(SAMPLE_PROFILE).unwrap();
332        assert_eq!(profile.rule_count(), 2 + 3 + 1 + 1); // 7
333    }
334
335    #[test]
336    fn empty_profile() {
337        let profile = AnalysisProfile::parse("[profile]\nname = \"empty\"").unwrap();
338        assert_eq!(profile.rule_count(), 0);
339        assert!(profile.is_sink("anything").is_none());
340    }
341
342    // ============================================================
343    // EMPTY PROFILE TESTS
344    // ============================================================
345
346    #[test]
347    fn parse_empty_profile_no_sources() {
348        let toml = r#"
349[profile]
350name = "no-sources"
351[[sinks]]
352api = "sink1"
353"#;
354        let profile = AnalysisProfile::parse(toml).unwrap();
355        assert!(profile.sources.is_empty());
356        assert_eq!(profile.sinks.len(), 1);
357    }
358
359    #[test]
360    fn parse_empty_profile_no_sinks() {
361        let toml = r#"
362[profile]
363name = "no-sinks"
364[[sources]]
365api = "source1"
366taint_id = 1
367"#;
368        let profile = AnalysisProfile::parse(toml).unwrap();
369        assert_eq!(profile.sources.len(), 1);
370        assert!(profile.sinks.is_empty());
371    }
372
373    #[test]
374    fn parse_empty_profile_no_sanitizers() {
375        let toml = r#"
376[profile]
377name = "no-sanitizers"
378[[sources]]
379api = "source1"
380taint_id = 1
381[[sinks]]
382api = "sink1"
383"#;
384        let profile = AnalysisProfile::parse(toml).unwrap();
385        assert!(profile.sanitizers.is_empty());
386    }
387
388    #[test]
389    fn parse_empty_profile_only_sources() {
390        let toml = r#"
391[profile]
392name = "only-sources"
393[[sources]]
394api = "source1"
395taint_id = 1
396[[sources]]
397api = "source2"
398taint_id = 2
399"#;
400        let profile = AnalysisProfile::parse(toml).unwrap();
401        assert_eq!(profile.sources.len(), 2);
402        assert!(profile.sinks.is_empty());
403        assert!(profile.transforms.is_empty());
404        assert!(profile.sanitizers.is_empty());
405    }
406
407    #[test]
408    fn parse_empty_profile_only_sinks() {
409        let toml = r#"
410[profile]
411name = "only-sinks"
412[[sinks]]
413api = "sink1"
414[[sinks]]
415api = "sink2"
416"#;
417        let profile = AnalysisProfile::parse(toml).unwrap();
418        assert!(profile.sources.is_empty());
419        assert_eq!(profile.sinks.len(), 2);
420    }
421
422    #[test]
423    fn parse_empty_profile_only_sanitizers() {
424        let toml = r#"
425[profile]
426name = "only-sanitizers"
427[[sanitizers]]
428api = "sanitizer1"
429kills_taint = ["xss"]
430"#;
431        let profile = AnalysisProfile::parse(toml).unwrap();
432        assert!(profile.sources.is_empty());
433        assert!(profile.sinks.is_empty());
434        assert_eq!(profile.sanitizers.len(), 1);
435    }
436
437    #[test]
438    fn parse_profile_only_transforms() {
439        let toml = r#"
440[profile]
441name = "only-transforms"
442[[transforms]]
443api = "transform1"
444propagates_taint = false
445"#;
446        let profile = AnalysisProfile::parse(toml).unwrap();
447        assert!(profile.sources.is_empty());
448        assert!(profile.sinks.is_empty());
449        assert_eq!(profile.transforms.len(), 1);
450        assert!(profile.sanitizers.is_empty());
451    }
452
453    // ============================================================
454    // INVALID TOML TESTS
455    // ============================================================
456
457    #[test]
458    fn parse_invalid_toml_syntax() {
459        let toml = r#"
460[profile
461name = "broken"
462"#;
463        let result = AnalysisProfile::parse(toml);
464        assert!(result.is_err());
465        assert!(result.unwrap_err().contains("parse error"));
466    }
467
468    #[test]
469    fn parse_toml_missing_bracket() {
470        let toml = r#"
471[profile]
472name = "test"
473[[sources
474api = "source1"
475taint_id = 1
476"#;
477        let result = AnalysisProfile::parse(toml);
478        assert!(result.is_err());
479    }
480
481    #[test]
482    fn parse_toml_wrong_type_for_taint_id() {
483        // taint_id should be an integer
484        let toml = r#"
485[profile]
486name = "test"
487[[sources]]
488api = "source1"
489taint_id = "not-a-number"
490"#;
491        let result = AnalysisProfile::parse(toml);
492        assert!(result.is_err());
493    }
494
495    #[test]
496    fn parse_toml_wrong_type_for_dangerous_arg() {
497        let toml = r#"
498[profile]
499name = "test"
500[[sinks]]
501api = "sink1"
502dangerous_arg = "not-a-number"
503"#;
504        let result = AnalysisProfile::parse(toml);
505        assert!(result.is_err());
506    }
507
508    #[test]
509    fn parse_toml_wrong_type_for_propagates_taint() {
510        let toml = r#"
511[profile]
512name = "test"
513[[transforms]]
514api = "transform1"
515propagates_taint = "not-a-boolean"
516"#;
517        let result = AnalysisProfile::parse(toml);
518        assert!(result.is_err());
519    }
520
521    #[test]
522    fn parse_toml_empty_string() {
523        // Empty string parses to default profile
524        let result = AnalysisProfile::parse("");
525        // TOML parser accepts empty strings as valid (returns default)
526        assert!(result.is_ok());
527        let profile = result.unwrap();
528        assert_eq!(profile.rule_count(), 0);
529    }
530
531    #[test]
532    fn parse_toml_whitespace_only() {
533        // Whitespace-only string also parses to default profile
534        let result = AnalysisProfile::parse("   \n\t  ");
535        assert!(result.is_ok());
536        let profile = result.unwrap();
537        assert_eq!(profile.rule_count(), 0);
538    }
539
540    #[test]
541    fn parse_toml_duplicate_table() {
542        // Duplicate tables may be allowed or rejected
543        let toml = r#"
544[profile]
545name = "test"
546[profile]
547name = "duplicate"
548"#;
549        let result = AnalysisProfile::parse(toml);
550        // This may succeed or fail depending on TOML parser behavior
551        // Let's just ensure it doesn't panic
552        let _ = result;
553    }
554
555    // ============================================================
556    // MERGE TESTS
557    // ============================================================
558
559    #[test]
560    fn merge_five_profiles_no_conflict() {
561        let profiles: Vec<AnalysisProfile> = (1..=5)
562            .map(|i| {
563                AnalysisProfile::parse(&format!(
564                    r#"
565[profile]
566name = "p{0}"
567[[sources]]
568api = "source{0}"
569taint_id = {0}
570[[sinks]]
571api = "sink{0}"
572"#,
573                    i
574                ))
575                .unwrap()
576            })
577            .collect();
578
579        let merged = AnalysisProfile::merge(profiles).unwrap();
580        assert_eq!(merged.sources.len(), 5);
581        assert_eq!(merged.sinks.len(), 5);
582    }
583
584    #[test]
585    fn merge_duplicate_taint_id_first_position() {
586        let p1 = AnalysisProfile::parse(
587            r#"
588[profile]
589name = "p1"
590[[sources]]
591api = "source1"
592taint_id = 1
593"#,
594        )
595        .unwrap();
596
597        let p2 = AnalysisProfile::parse(
598            r#"
599[profile]
600name = "p2"
601[[sources]]
602api = "source2"
603taint_id = 1
604"#,
605        )
606        .unwrap();
607
608        let result = AnalysisProfile::merge(vec![p1, p2]);
609        assert!(result.is_err());
610        let err = result.unwrap_err();
611        assert!(err.contains("duplicate taint_id 1"));
612        assert!(err.contains("source2")); // The duplicate
613        assert!(err.contains("p2")); // The profile name
614    }
615
616    #[test]
617    fn merge_duplicate_taint_id_middle() {
618        let p1 = AnalysisProfile::parse(
619            r#"
620[profile]
621name = "p1"
622[[sources]]
623api = "source1"
624taint_id = 1
625"#,
626        )
627        .unwrap();
628
629        let p2 = AnalysisProfile::parse(
630            r#"
631[profile]
632name = "p2"
633[[sources]]
634api = "source2"
635taint_id = 2
636"#,
637        )
638        .unwrap();
639
640        let p3 = AnalysisProfile::parse(
641            r#"
642[profile]
643name = "p3"
644[[sources]]
645api = "source3"
646taint_id = 2
647"#,
648        )
649        .unwrap();
650
651        let result = AnalysisProfile::merge(vec![p1, p2, p3]);
652        assert!(result.is_err());
653    }
654
655    #[test]
656    fn merge_duplicate_taint_id_last_position() {
657        let profiles: Vec<AnalysisProfile> = (1..=5)
658            .map(|i| {
659                AnalysisProfile::parse(&format!(
660                    r#"
661[profile]
662name = "p{0}"
663[[sources]]
664api = "source{0}"
665taint_id = {0}
666"#,
667                    i
668                ))
669                .unwrap()
670            })
671            .collect();
672
673        let p6 = AnalysisProfile::parse(
674            r#"
675[profile]
676name = "p6"
677[[sources]]
678api = "source6"
679taint_id = 5
680"#,
681        )
682        .unwrap();
683
684        let mut all_profiles = profiles;
685        all_profiles.push(p6);
686
687        let result = AnalysisProfile::merge(all_profiles);
688        assert!(result.is_err());
689    }
690
691    #[test]
692    fn merge_empty_vec() {
693        let merged = AnalysisProfile::merge(vec![]).unwrap();
694        assert_eq!(merged.rule_count(), 0);
695        assert_eq!(merged.profile.name, "merged");
696    }
697
698    #[test]
699    fn merge_single_profile() {
700        let p1 = AnalysisProfile::parse(
701            r#"
702[profile]
703name = "p1"
704[[sources]]
705api = "source1"
706taint_id = 1
707[[sinks]]
708api = "sink1"
709"#,
710        )
711        .unwrap();
712
713        let merged = AnalysisProfile::merge(vec![p1]).unwrap();
714        assert_eq!(merged.sources.len(), 1);
715        assert_eq!(merged.sinks.len(), 1);
716    }
717
718    #[test]
719    fn merge_preserves_all_sinks() {
720        let p1 = AnalysisProfile::parse(
721            r#"
722[[sinks]]
723api = "sink1"
724[[sinks]]
725api = "sink2"
726"#,
727        )
728        .unwrap();
729
730        let p2 = AnalysisProfile::parse(
731            r#"
732[[sinks]]
733api = "sink3"
734"#,
735        )
736        .unwrap();
737
738        let merged = AnalysisProfile::merge(vec![p1, p2]).unwrap();
739        assert_eq!(merged.sinks.len(), 3);
740        assert!(merged.is_sink("sink1").is_some());
741        assert!(merged.is_sink("sink2").is_some());
742        assert!(merged.is_sink("sink3").is_some());
743    }
744
745    // ============================================================
746    // IS_SINK / IS_SOURCE PARTIAL MATCH TESTS
747    // ============================================================
748
749    #[test]
750    fn is_sink_exact_match_required() {
751        let toml = r#"
752[[sinks]]
753api = "chrome.tabs.executeScript"
754"#;
755        let profile = AnalysisProfile::parse(toml).unwrap();
756        assert!(profile.is_sink("chrome.tabs.executeScript").is_some());
757        // Partial matches should NOT match
758        assert!(profile.is_sink("chrome.tabs").is_none());
759        assert!(profile.is_sink("tabs.executeScript").is_none());
760        assert!(profile.is_sink("executeScript").is_none());
761        assert!(
762            profile
763                .is_sink("chrome.tabs.executeScript.details")
764                .is_none()
765        );
766    }
767
768    #[test]
769    fn is_source_exact_match_required() {
770        let toml = r#"
771[[sources]]
772api = "chrome.runtime.onMessage"
773taint_id = 1
774"#;
775        let profile = AnalysisProfile::parse(toml).unwrap();
776        assert!(profile.is_source("chrome.runtime.onMessage").is_some());
777        assert!(profile.is_source("chrome.runtime").is_none());
778        assert!(profile.is_source("runtime.onMessage").is_none());
779        assert!(profile.is_source("onMessage").is_none());
780    }
781
782    #[test]
783    fn is_sink_case_sensitive() {
784        let toml = r#"
785[[sinks]]
786api = "Eval"
787"#;
788        let profile = AnalysisProfile::parse(toml).unwrap();
789        assert!(profile.is_sink("Eval").is_some());
790        // Case sensitivity depends on implementation
791        // Let's document the actual behavior
792        let result = profile.is_sink("eval");
793        let _ = result; // May be Some or None
794    }
795
796    #[test]
797    fn is_sink_empty_string() {
798        let toml = r#"
799[[sinks]]
800api = ""
801"#;
802        let profile = AnalysisProfile::parse(toml).unwrap();
803        assert!(profile.is_sink("").is_some());
804        assert!(profile.is_sink("something").is_none());
805    }
806
807    // ============================================================
808    // KILLS_TAINT TESTS
809    // ============================================================
810
811    #[test]
812    fn kills_taint_multiple_categories() {
813        let toml = r#"
814[[sanitizers]]
815api = "sanitize"
816kills_taint = ["xss", "sqli", "commandi"]
817"#;
818        let profile = AnalysisProfile::parse(toml).unwrap();
819        assert!(profile.kills_taint("sanitize", "xss"));
820        assert!(profile.kills_taint("sanitize", "sqli"));
821        assert!(profile.kills_taint("sanitize", "commandi"));
822        assert!(!profile.kills_taint("sanitize", "path_traversal"));
823    }
824
825    #[test]
826    fn kills_taint_empty_categories() {
827        let toml = r#"
828[[sanitizers]]
829api = "sanitize"
830kills_taint = []
831"#;
832        let profile = AnalysisProfile::parse(toml).unwrap();
833        assert!(!profile.kills_taint("sanitize", "xss"));
834    }
835
836    #[test]
837    fn kills_taint_no_sanitizer_match() {
838        let toml = r#"
839[[sanitizers]]
840api = "DOMPurify.sanitize"
841kills_taint = ["xss"]
842"#;
843        let profile = AnalysisProfile::parse(toml).unwrap();
844        assert!(!profile.kills_taint("OtherSanitizer", "xss"));
845    }
846
847    #[test]
848    fn kills_taint_case_sensitive_category() {
849        let toml = r#"
850[[sanitizers]]
851api = "sanitize"
852kills_taint = ["XSS"]
853"#;
854        let profile = AnalysisProfile::parse(toml).unwrap();
855        assert!(profile.kills_taint("sanitize", "XSS"));
856        // Category matching is case-sensitive
857        assert!(!profile.kills_taint("sanitize", "xss"));
858    }
859
860    #[test]
861    fn kills_taint_multiple_sanitizers() {
862        let toml = r#"
863[[sanitizers]]
864api = "sanitizer1"
865kills_taint = ["xss"]
866[[sanitizers]]
867api = "sanitizer2"
868kills_taint = ["sqli"]
869"#;
870        let profile = AnalysisProfile::parse(toml).unwrap();
871        assert!(profile.kills_taint("sanitizer1", "xss"));
872        assert!(profile.kills_taint("sanitizer2", "sqli"));
873        assert!(!profile.kills_taint("sanitizer1", "sqli"));
874        assert!(!profile.kills_taint("sanitizer2", "xss"));
875    }
876
877    // ============================================================
878    // UNICODE AND SPECIAL CHARACTER TESTS
879    // ============================================================
880
881    #[test]
882    fn profile_with_unicode_api_names() {
883        let toml = r#"
884[profile]
885name = "unicode"
886[[sources]]
887api = "日本語.メッセージ"
888taint_id = 1
889[[sinks]]
890api = "🎉.celebrate"
891"#;
892        let profile = AnalysisProfile::parse(toml).unwrap();
893        assert!(profile.is_source("日本語.メッセージ").is_some());
894        assert!(profile.is_sink("🎉.celebrate").is_some());
895    }
896
897    #[test]
898    fn profile_with_unicode_in_description() {
899        let toml = r#"
900[profile]
901name = "unicode"
902description = "日本語の説明"
903[[sources]]
904api = "source"
905taint_id = 1
906description = "這是中文"
907"#;
908        let profile = AnalysisProfile::parse(toml).unwrap();
909        assert_eq!(profile.profile.description, "日本語の説明");
910        assert_eq!(profile.sources[0].description, "這是中文");
911    }
912
913    #[test]
914    fn profile_with_special_chars_in_api() {
915        let toml = r#"
916[[sources]]
917api = "api-name_with.special$chars"
918taint_id = 1
919"#;
920        let profile = AnalysisProfile::parse(toml).unwrap();
921        assert!(profile.is_source("api-name_with.special$chars").is_some());
922    }
923
924    // ============================================================
925    // EMPTY STRING TESTS
926    // ============================================================
927
928    #[test]
929    fn profile_empty_api_name() {
930        let toml = r#"
931[[sources]]
932api = ""
933taint_id = 1
934[[sinks]]
935api = ""
936"#;
937        let profile = AnalysisProfile::parse(toml).unwrap();
938        assert!(profile.is_source("").is_some());
939        assert!(profile.is_sink("").is_some());
940    }
941
942    #[test]
943    fn profile_empty_profile_name() {
944        let toml = r#"
945[profile]
946name = ""
947"#;
948        let profile = AnalysisProfile::parse(toml).unwrap();
949        assert_eq!(profile.profile.name, "");
950    }
951
952    #[test]
953    fn profile_empty_cwe() {
954        let toml = r#"
955[[sinks]]
956api = "sink1"
957cwe = ""
958"#;
959        let profile = AnalysisProfile::parse(toml).unwrap();
960        assert_eq!(profile.sinks[0].cwe, "");
961    }
962
963    #[test]
964    fn profile_empty_severity() {
965        let toml = r#"
966[[sinks]]
967api = "sink1"
968severity = ""
969"#;
970        let profile = AnalysisProfile::parse(toml).unwrap();
971        // Empty severity overrides default
972        assert_eq!(profile.sinks[0].severity, "");
973    }
974
975    // ============================================================
976    // RULE COUNT TESTS
977    // ============================================================
978
979    #[test]
980    fn rule_count_empty() {
981        let profile = AnalysisProfile::default();
982        assert_eq!(profile.rule_count(), 0);
983    }
984
985    #[test]
986    fn rule_count_only_sources() {
987        let toml = r#"
988[[sources]]
989api = "s1"
990taint_id = 1
991[[sources]]
992api = "s2"
993taint_id = 2
994"#;
995        let profile = AnalysisProfile::parse(toml).unwrap();
996        assert_eq!(profile.rule_count(), 2);
997    }
998
999    #[test]
1000    fn rule_count_only_sinks() {
1001        let toml = r#"
1002[[sinks]]
1003api = "sink1"
1004[[sinks]]
1005api = "sink2"
1006[[sinks]]
1007api = "sink3"
1008"#;
1009        let profile = AnalysisProfile::parse(toml).unwrap();
1010        assert_eq!(profile.rule_count(), 3);
1011    }
1012
1013    #[test]
1014    fn rule_count_only_transforms() {
1015        let toml = r#"
1016[[transforms]]
1017api = "t1"
1018[[transforms]]
1019api = "t2"
1020"#;
1021        let profile = AnalysisProfile::parse(toml).unwrap();
1022        assert_eq!(profile.rule_count(), 2);
1023    }
1024
1025    #[test]
1026    fn rule_count_only_sanitizers() {
1027        let toml = r#"
1028[[sanitizers]]
1029api = "s1"
1030[[sanitizers]]
1031api = "s2"
1032[[sanitizers]]
1033api = "s3"
1034[[sanitizers]]
1035api = "s4"
1036"#;
1037        let profile = AnalysisProfile::parse(toml).unwrap();
1038        assert_eq!(profile.rule_count(), 4);
1039    }
1040
1041    #[test]
1042    fn rule_count_mixed() {
1043        let toml = r#"
1044[[sources]]
1045api = "s1"
1046taint_id = 1
1047[[sinks]]
1048api = "sink1"
1049[[transforms]]
1050api = "t1"
1051[[sanitizers]]
1052api = "san1"
1053"#;
1054        let profile = AnalysisProfile::parse(toml).unwrap();
1055        assert_eq!(profile.rule_count(), 4);
1056    }
1057
1058    #[test]
1059    fn rule_count_large_profile() {
1060        let sources: String = (1..=100)
1061            .map(|i| format!("[[sources]]\napi = \"source{}\"\ntaint_id = {}\n", i, i))
1062            .collect();
1063        let profile = AnalysisProfile::parse(&sources).unwrap();
1064        assert_eq!(profile.rule_count(), 100);
1065    }
1066
1067    // ============================================================
1068    // DEFAULT VALUES TESTS
1069    // ============================================================
1070
1071    #[test]
1072    fn default_severity_is_high() {
1073        let toml = r#"
1074[[sinks]]
1075api = "sink1"
1076"#;
1077        let profile = AnalysisProfile::parse(toml).unwrap();
1078        assert_eq!(profile.sinks[0].severity, "high");
1079    }
1080
1081    #[test]
1082    fn default_extract_is_args0() {
1083        let toml = r#"
1084[[sources]]
1085api = "source1"
1086taint_id = 1
1087"#;
1088        let profile = AnalysisProfile::parse(toml).unwrap();
1089        assert_eq!(profile.sources[0].extract, "args[0]");
1090    }
1091
1092    #[test]
1093    fn default_propagates_taint_is_true() {
1094        let toml = r#"
1095[[transforms]]
1096api = "transform1"
1097"#;
1098        let profile = AnalysisProfile::parse(toml).unwrap();
1099        assert!(profile.transforms[0].propagates_taint);
1100    }
1101
1102    #[test]
1103    fn default_dangerous_arg_is_0() {
1104        let toml = r#"
1105[[sinks]]
1106api = "sink1"
1107"#;
1108        let profile = AnalysisProfile::parse(toml).unwrap();
1109        assert_eq!(profile.sinks[0].dangerous_arg, 0);
1110    }
1111
1112    #[test]
1113    fn default_kills_taint_is_empty() {
1114        let toml = r#"
1115[[sanitizers]]
1116api = "sanitizer1"
1117"#;
1118        let profile = AnalysisProfile::parse(toml).unwrap();
1119        assert!(profile.sanitizers[0].kills_taint.is_empty());
1120    }
1121
1122    #[test]
1123    fn default_profile_fields() {
1124        let toml = r#"
1125[profile]
1126name = "test"
1127"#;
1128        let profile = AnalysisProfile::parse(toml).unwrap();
1129        assert_eq!(profile.profile.description, "");
1130        assert_eq!(profile.profile.version, "");
1131        assert_eq!(profile.profile.author, "");
1132    }
1133
1134    #[test]
1135    fn explicit_values_override_defaults() {
1136        let toml = r#"
1137[[sinks]]
1138api = "sink1"
1139severity = "critical"
1140dangerous_arg = 2
1141[[sources]]
1142api = "source1"
1143taint_id = 1
1144extract = "return"
1145[[transforms]]
1146api = "transform1"
1147propagates_taint = false
1148"#;
1149        let profile = AnalysisProfile::parse(toml).unwrap();
1150        assert_eq!(profile.sinks[0].severity, "critical");
1151        assert_eq!(profile.sinks[0].dangerous_arg, 2);
1152        assert_eq!(profile.sources[0].extract, "return");
1153        assert!(!profile.transforms[0].propagates_taint);
1154    }
1155
1156    // ============================================================
1157    // SOURCE FIELD TESTS
1158    // ============================================================
1159
1160    #[test]
1161    fn source_with_all_fields() {
1162        let toml = r#"
1163[[sources]]
1164api = "chrome.runtime.onMessage"
1165taint_id = 42
1166extract = "event.data"
1167description = "Message from runtime"
1168"#;
1169        let profile = AnalysisProfile::parse(toml).unwrap();
1170        let source = &profile.sources[0];
1171        assert_eq!(source.api, "chrome.runtime.onMessage");
1172        assert_eq!(source.taint_id, 42);
1173        assert_eq!(source.extract, "event.data");
1174        assert_eq!(source.description, "Message from runtime");
1175    }
1176
1177    #[test]
1178    fn source_large_taint_id() {
1179        let toml = r#"
1180[[sources]]
1181api = "source1"
1182taint_id = 4294967295
1183"#;
1184        let profile = AnalysisProfile::parse(toml).unwrap();
1185        assert_eq!(profile.sources[0].taint_id, 4294967295);
1186    }
1187
1188    #[test]
1189    fn source_taint_id_zero() {
1190        let toml = r#"
1191[[sources]]
1192api = "source1"
1193taint_id = 0
1194"#;
1195        let profile = AnalysisProfile::parse(toml).unwrap();
1196        assert_eq!(profile.sources[0].taint_id, 0);
1197    }
1198
1199    // ============================================================
1200    // SINK FIELD TESTS
1201    // ============================================================
1202
1203    #[test]
1204    fn sink_with_all_fields() {
1205        let toml = r#"
1206[[sinks]]
1207api = "eval"
1208dangerous_arg = 0
1209severity = "critical"
1210cwe = "CWE-95"
1211description = "Code execution"
1212"#;
1213        let profile = AnalysisProfile::parse(toml).unwrap();
1214        let sink = profile.is_sink("eval").unwrap();
1215        assert_eq!(sink.dangerous_arg, 0);
1216        assert_eq!(sink.severity, "critical");
1217        assert_eq!(sink.cwe, "CWE-95");
1218        assert_eq!(sink.description, "Code execution");
1219    }
1220
1221    #[test]
1222    fn sink_large_dangerous_arg() {
1223        let toml = r#"
1224[[sinks]]
1225api = "sink1"
1226dangerous_arg = 999
1227"#;
1228        let profile = AnalysisProfile::parse(toml).unwrap();
1229        assert_eq!(profile.sinks[0].dangerous_arg, 999);
1230    }
1231
1232    // ============================================================
1233    // TRANSFORM FIELD TESTS
1234    // ============================================================
1235
1236    #[test]
1237    fn transform_propagates_true() {
1238        let toml = r#"
1239[[transforms]]
1240api = "JSON.parse"
1241propagates_taint = true
1242"#;
1243        let profile = AnalysisProfile::parse(toml).unwrap();
1244        assert!(profile.transforms[0].propagates_taint);
1245    }
1246
1247    #[test]
1248    fn transform_propagates_false() {
1249        let toml = r#"
1250[[transforms]]
1251api = "toString"
1252propagates_taint = false
1253"#;
1254        let profile = AnalysisProfile::parse(toml).unwrap();
1255        assert!(!profile.transforms[0].propagates_taint);
1256    }
1257
1258    // ============================================================
1259    // SANITIZER FIELD TESTS
1260    // ============================================================
1261
1262    #[test]
1263    fn sanitizer_single_category() {
1264        let toml = r#"
1265[[sanitizers]]
1266api = "sanitize"
1267kills_taint = ["xss"]
1268"#;
1269        let profile = AnalysisProfile::parse(toml).unwrap();
1270        assert_eq!(profile.sanitizers[0].kills_taint.len(), 1);
1271    }
1272
1273    #[test]
1274    fn sanitizer_many_categories() {
1275        let toml = r#"
1276[[sanitizers]]
1277api = "superSanitizer"
1278kills_taint = ["xss", "sqli", "commandi", "path_traversal", "ssrf", "xxe", "ldap_injection"]
1279"#;
1280        let profile = AnalysisProfile::parse(toml).unwrap();
1281        assert_eq!(profile.sanitizers[0].kills_taint.len(), 7);
1282    }
1283}