Skip to main content

code_baseline/rules/
banned_dependency.rs

1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use std::collections::HashSet;
4
5/// Checks `package.json` (or other manifest) files for banned packages
6/// in dependency sections.
7///
8/// Scans `dependencies`, `devDependencies`, `peerDependencies`, and
9/// `optionalDependencies` for packages that should not be used.
10#[derive(Debug)]
11pub struct BannedDependencyRule {
12    id: String,
13    severity: Severity,
14    message: String,
15    suggest: Option<String>,
16    glob: Option<String>,
17    packages: HashSet<String>,
18    manifest: String,
19}
20
21/// JSON dependency sections to check.
22const DEP_SECTIONS: &[&str] = &[
23    "dependencies",
24    "devDependencies",
25    "peerDependencies",
26    "optionalDependencies",
27];
28
29impl BannedDependencyRule {
30    pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
31        if config.packages.is_empty() {
32            return Err(RuleBuildError::MissingField(
33                config.id.clone(),
34                "packages",
35            ));
36        }
37
38        let packages: HashSet<String> = config.packages.iter().cloned().collect();
39        let manifest = config
40            .manifest
41            .as_deref()
42            .unwrap_or("package.json")
43            .to_string();
44
45        // Build a glob that only matches the manifest filename
46        let glob = config
47            .glob
48            .clone()
49            .or_else(|| Some(format!("**/{}", manifest)));
50
51        Ok(Self {
52            id: config.id.clone(),
53            severity: config.severity,
54            message: config.message.clone(),
55            suggest: config.suggest.clone(),
56            glob,
57            packages,
58            manifest,
59        })
60    }
61}
62
63impl Rule for BannedDependencyRule {
64    fn id(&self) -> &str {
65        &self.id
66    }
67
68    fn severity(&self) -> Severity {
69        self.severity
70    }
71
72    fn file_glob(&self) -> Option<&str> {
73        self.glob.as_deref()
74    }
75
76    fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
77        // Only process files that match the manifest name
78        let file_name = ctx
79            .file_path
80            .file_name()
81            .and_then(|n| n.to_str())
82            .unwrap_or("");
83
84        if file_name != self.manifest {
85            return Vec::new();
86        }
87
88        let json: serde_json::Value = match serde_json::from_str(ctx.content) {
89            Ok(v) => v,
90            Err(_) => return Vec::new(), // skip malformed JSON
91        };
92
93        let mut violations = Vec::new();
94
95        for section in DEP_SECTIONS {
96            if let Some(deps) = json.get(section).and_then(|v| v.as_object()) {
97                for pkg_name in deps.keys() {
98                    if self.packages.contains(pkg_name) {
99                        // Find the line number by searching for the package name in the raw text
100                        let line_num = find_line_number(ctx.content, pkg_name, section);
101
102                        violations.push(Violation {
103                            rule_id: self.id.clone(),
104                            severity: self.severity,
105                            file: ctx.file_path.to_path_buf(),
106                            line: line_num,
107                            column: None,
108                            message: format!(
109                                "{}: '{}' in {}",
110                                self.message, pkg_name, section
111                            ),
112                            suggest: self.suggest.clone(),
113                            source_line: line_num.and_then(|n| {
114                                ctx.content.lines().nth(n - 1).map(|l| l.to_string())
115                            }),
116                            fix: None,
117                        });
118                    }
119                }
120            }
121        }
122
123        violations
124    }
125}
126
127/// Find the line number of a package name within a specific dependency section.
128fn find_line_number(content: &str, pkg_name: &str, section: &str) -> Option<usize> {
129    let needle = format!(r#""{}""#, pkg_name);
130    let section_needle = format!(r#""{}""#, section);
131
132    let mut in_section = false;
133    let mut brace_depth = 0;
134
135    for (idx, line) in content.lines().enumerate() {
136        if line.contains(&section_needle) {
137            in_section = true;
138            brace_depth = 0;
139            continue;
140        }
141
142        if in_section {
143            brace_depth += line.matches('{').count() as i32;
144            brace_depth -= line.matches('}').count() as i32;
145
146            if brace_depth < 0 {
147                in_section = false;
148                continue;
149            }
150
151            if line.contains(&needle) {
152                return Some(idx + 1);
153            }
154        }
155    }
156
157    // Fallback: search anywhere in the file
158    for (idx, line) in content.lines().enumerate() {
159        if line.contains(&needle) {
160            return Some(idx + 1);
161        }
162    }
163
164    None
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use std::path::Path;
171
172    fn make_rule(packages: Vec<&str>) -> BannedDependencyRule {
173        let config = RuleConfig {
174            id: "test-banned-dep".into(),
175            severity: Severity::Error,
176            message: "banned dependency".into(),
177            suggest: Some("remove this package".into()),
178            packages: packages.into_iter().map(|s| s.to_string()).collect(),
179            ..Default::default()
180        };
181        BannedDependencyRule::new(&config).unwrap()
182    }
183
184    fn check(rule: &BannedDependencyRule, content: &str) -> Vec<Violation> {
185        let ctx = ScanContext {
186            file_path: Path::new("package.json"),
187            content,
188        };
189        rule.check_file(&ctx)
190    }
191
192    #[test]
193    fn detects_dependency() {
194        let rule = make_rule(vec!["bootstrap"]);
195        let content = r#"{
196  "dependencies": {
197    "bootstrap": "^5.0.0",
198    "react": "^18.0.0"
199  }
200}"#;
201        let violations = check(&rule, content);
202        assert_eq!(violations.len(), 1);
203        assert!(violations[0].message.contains("bootstrap"));
204        assert!(violations[0].message.contains("dependencies"));
205    }
206
207    #[test]
208    fn detects_dev_dependency() {
209        let rule = make_rule(vec!["bootstrap"]);
210        let content = r#"{
211  "devDependencies": {
212    "bootstrap": "^5.0.0"
213  }
214}"#;
215        let violations = check(&rule, content);
216        assert_eq!(violations.len(), 1);
217        assert!(violations[0].message.contains("devDependencies"));
218    }
219
220    #[test]
221    fn detects_multiple_banned_packages() {
222        let rule = make_rule(vec!["bootstrap", "@mui/material"]);
223        let content = r#"{
224  "dependencies": {
225    "bootstrap": "^5.0.0",
226    "@mui/material": "^5.0.0",
227    "react": "^18.0.0"
228  }
229}"#;
230        let violations = check(&rule, content);
231        assert_eq!(violations.len(), 2);
232    }
233
234    #[test]
235    fn no_match_on_safe_deps() {
236        let rule = make_rule(vec!["bootstrap"]);
237        let content = r#"{
238  "dependencies": {
239    "react": "^18.0.0",
240    "next": "^14.0.0"
241  }
242}"#;
243        let violations = check(&rule, content);
244        assert!(violations.is_empty());
245    }
246
247    #[test]
248    fn skips_non_manifest_files() {
249        let rule = make_rule(vec!["bootstrap"]);
250        let ctx = ScanContext {
251            file_path: Path::new("src/component.tsx"),
252            content: r#"{"dependencies": {"bootstrap": "^5.0.0"}}"#,
253        };
254        let violations = rule.check_file(&ctx);
255        assert!(violations.is_empty());
256    }
257
258    #[test]
259    fn skips_malformed_json() {
260        let rule = make_rule(vec!["bootstrap"]);
261        let violations = check(&rule, "not valid json {{{");
262        assert!(violations.is_empty());
263    }
264
265    #[test]
266    fn finds_line_numbers() {
267        let rule = make_rule(vec!["bootstrap"]);
268        let content = r#"{
269  "name": "my-app",
270  "dependencies": {
271    "bootstrap": "^5.0.0"
272  }
273}"#;
274        let violations = check(&rule, content);
275        assert_eq!(violations.len(), 1);
276        assert_eq!(violations[0].line, Some(4));
277    }
278
279    #[test]
280    fn missing_packages_error() {
281        let config = RuleConfig {
282            id: "test".into(),
283            severity: Severity::Error,
284            message: "test".into(),
285            ..Default::default()
286        };
287        let err = BannedDependencyRule::new(&config).unwrap_err();
288        assert!(matches!(err, RuleBuildError::MissingField(_, "packages")));
289    }
290
291    #[test]
292    fn custom_manifest_name() {
293        let config = RuleConfig {
294            id: "test-banned-dep".into(),
295            severity: Severity::Error,
296            message: "banned dependency".into(),
297            packages: vec!["bootstrap".to_string()],
298            manifest: Some("bower.json".to_string()),
299            ..Default::default()
300        };
301        let rule = BannedDependencyRule::new(&config).unwrap();
302
303        // Should not match package.json
304        let ctx = ScanContext {
305            file_path: Path::new("package.json"),
306            content: r#"{"dependencies": {"bootstrap": "^5.0.0"}}"#,
307        };
308        assert!(rule.check_file(&ctx).is_empty());
309
310        // Should match bower.json
311        let ctx = ScanContext {
312            file_path: Path::new("bower.json"),
313            content: r#"{"dependencies": {"bootstrap": "^5.0.0"}}"#,
314        };
315        assert_eq!(rule.check_file(&ctx).len(), 1);
316    }
317}