1use std::path::Path;
21
22use alint_core::{
23 Context, Error, Level, PerFileRule, Result, Rule, RuleSpec, Scope, Violation, eval_per_file,
24};
25use regex::Regex;
26use serde::Deserialize;
27
28#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "lowercase")]
30enum Language {
31 Go,
32 Python,
33 Rust,
34 Js,
35 Generic,
37}
38
39impl Language {
40 fn default_pattern(self) -> Option<&'static str> {
45 Some(match self {
46 Self::Go => r#"^\s*(?:import\s+)?(?:_\s+|[A-Za-z][\w.]*\s+)?"([^"]+)"\s*(?://.*)?$"#,
51 Self::Python => r"^\s*(?:from|import)\s+([\w.]+)",
53 Self::Rust => r"^\s*(?:pub\s+)?use\s+([\w:]+)",
55 Self::Js => r#"(?:from\s*|require\s*\(\s*|import\s*\(\s*|import\s+)['"]([^'"]+)['"]"#,
58 Self::Generic => return None,
59 })
60 }
61}
62
63#[derive(Debug, Deserialize)]
64#[serde(deny_unknown_fields)]
65struct Options {
66 forbid: String,
67 #[serde(default)]
68 language: Option<Language>,
69 #[serde(default)]
72 import_pattern: Option<String>,
73 #[serde(default)]
75 allow: Vec<String>,
76}
77
78#[derive(Debug)]
79pub struct ImportGateRule {
80 id: String,
81 level: Level,
82 policy_url: Option<String>,
83 message: Option<String>,
84 scope: Scope,
85 forbid_src: String,
86 forbid: Regex,
87 import_re: Regex,
88 allow: Option<Scope>,
89}
90
91impl Rule for ImportGateRule {
92 alint_core::rule_common_impl!();
93
94 fn path_scope(&self) -> Option<&Scope> {
95 Some(&self.scope)
96 }
97
98 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
99 eval_per_file(self, ctx)
100 }
101
102 fn as_per_file(&self) -> Option<&dyn PerFileRule> {
103 Some(self)
104 }
105}
106
107impl PerFileRule for ImportGateRule {
108 fn path_scope(&self) -> &Scope {
109 &self.scope
110 }
111
112 fn evaluate_file(
113 &self,
114 ctx: &Context<'_>,
115 path: &Path,
116 bytes: &[u8],
117 ) -> Result<Vec<Violation>> {
118 if self
121 .allow
122 .as_ref()
123 .is_some_and(|a| a.matches(path, ctx.index))
124 {
125 return Ok(Vec::new());
126 }
127 let Ok(text) = std::str::from_utf8(bytes) else {
128 return Ok(Vec::new());
129 };
130 let mut violations = Vec::new();
131 for (i, line) in text.lines().enumerate() {
132 let Some(caps) = self.import_re.captures(line) else {
133 continue;
134 };
135 let Some(target) = caps.get(1).map(|m| m.as_str()) else {
136 continue;
137 };
138 if self.forbid.is_match(target) {
139 let msg = self.message.clone().unwrap_or_else(|| {
140 format!(
141 "forbidden import {target:?} at this scope (matches /{}/)",
142 self.forbid_src
143 )
144 });
145 violations.push(
146 Violation::new(msg)
147 .with_path(std::sync::Arc::<Path>::from(path))
148 .with_location(i + 1, 1),
149 );
150 }
151 }
152 Ok(violations)
153 }
154}
155
156pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
157 if spec.paths.is_none() {
158 return Err(Error::rule_config(
159 &spec.id,
160 "import_gate requires a `paths` field (the scope the gate applies to)",
161 ));
162 }
163 let opts: Options = spec
164 .deserialize_options()
165 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
166
167 let pattern_src: String = match (&opts.import_pattern, opts.language) {
170 (Some(p), _) => p.clone(),
171 (None, Some(lang)) => lang
172 .default_pattern()
173 .ok_or_else(|| {
174 Error::rule_config(
175 &spec.id,
176 "import_gate `language: generic` requires an explicit `import_pattern`",
177 )
178 })?
179 .to_string(),
180 (None, None) => {
181 return Err(Error::rule_config(
182 &spec.id,
183 "import_gate requires `language:` (go/python/rust/js) or `import_pattern:`",
184 ));
185 }
186 };
187 let import_re = Regex::new(&pattern_src)
188 .map_err(|e| Error::rule_config(&spec.id, format!("invalid `import_pattern`: {e}")))?;
189 let forbid = Regex::new(&opts.forbid)
190 .map_err(|e| Error::rule_config(&spec.id, format!("invalid `forbid` regex: {e}")))?;
191 let allow = if opts.allow.is_empty() {
192 None
193 } else {
194 Some(
195 Scope::from_patterns(&opts.allow)
196 .map_err(|e| Error::rule_config(&spec.id, format!("invalid `allow` glob: {e}")))?,
197 )
198 };
199
200 Ok(Box::new(ImportGateRule {
201 id: spec.id.clone(),
202 level: spec.level,
203 policy_url: spec.policy_url.clone(),
204 message: spec.message.clone(),
205 scope: Scope::from_spec(spec)?,
206 forbid_src: opts.forbid,
207 forbid,
208 import_re,
209 allow,
210 }))
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 fn rule(language: Language, forbid: &str, allow: &[&str]) -> ImportGateRule {
218 let pattern = language.default_pattern().expect("preset has a pattern");
219 ImportGateRule {
220 id: "t".into(),
221 level: Level::Error,
222 policy_url: None,
223 message: None,
224 scope: Scope::from_patterns(&["**/*".to_string()]).unwrap(),
225 forbid_src: forbid.into(),
226 forbid: Regex::new(forbid).unwrap(),
227 import_re: Regex::new(pattern).unwrap(),
228 allow: if allow.is_empty() {
229 None
230 } else {
231 Some(
232 Scope::from_patterns(
233 &allow.iter().map(ToString::to_string).collect::<Vec<_>>(),
234 )
235 .unwrap(),
236 )
237 },
238 }
239 }
240
241 fn eval(r: &ImportGateRule, path: &str, src: &str) -> Vec<Violation> {
242 let idx = alint_core::FileIndex::from_entries(vec![alint_core::FileEntry {
243 path: Path::new(path).into(),
244 is_dir: false,
245 size: 1,
246 }]);
247 let ctx = Context {
248 root: Path::new("/"),
249 index: &idx,
250 registry: None,
251 facts: None,
252 vars: None,
253 git_tracked: None,
254 git_blame: None,
255 };
256 r.evaluate_file(&ctx, Path::new(path), src.as_bytes())
257 .unwrap()
258 }
259
260 #[test]
261 fn go_grouped_and_single_imports_are_gated() {
262 let r = rule(Language::Go, r"^k8s\.io/kubernetes/", &[]);
263 let src = "package x\n\nimport (\n\t\"fmt\"\n\t\"k8s.io/kubernetes/pkg/api\"\n)\n\nimport \"k8s.io/kubernetes/cmd\"\n";
264 let v = eval(&r, "staging/a.go", src);
265 assert_eq!(v.len(), 2, "both forbidden imports flagged: {v:?}");
266 assert!(v[0].message.contains("k8s.io/kubernetes/pkg/api"));
267 assert!(v.iter().all(|x| !x.message.contains("\"fmt\"")));
268 }
269
270 #[test]
271 fn target_not_raw_line_no_false_positive_on_comment() {
272 let r = rule(Language::Go, r"^k8s\.io/kubernetes/", &[]);
273 let src = "package x\n// see k8s.io/kubernetes/pkg for context\nvar s = \"k8s.io/kubernetes/x is a path\"\n";
276 assert!(eval(&r, "staging/a.go", src).is_empty());
277 }
278
279 #[test]
280 fn allow_glob_exempts_a_scoped_file() {
281 let r = rule(
282 Language::Go,
283 r"^k8s\.io/kubernetes/",
284 &["staging/legacy/**"],
285 );
286 let src = "import \"k8s.io/kubernetes/pkg\"\n";
287 assert_eq!(eval(&r, "staging/a.go", src).len(), 1);
288 assert!(eval(&r, "staging/legacy/old.go", src).is_empty());
289 }
290
291 #[test]
292 fn python_from_and_import_forms() {
293 let r = rule(Language::Python, r"^airflow\.providers", &[]);
294 let src =
295 "from airflow.providers.amazon import S3\nimport airflow.providers.google\nimport os\n";
296 let v = eval(&r, "airflow/core/x.py", src);
297 assert_eq!(v.len(), 2, "{v:?}");
298 assert!(
299 eval(
300 &r,
301 "airflow/core/x.py",
302 "import os\nfrom airflow.models import DAG\n"
303 )
304 .is_empty()
305 );
306 }
307
308 #[test]
309 fn rust_use_paths() {
310 let r = rule(Language::Rust, r"^crate::secrets", &[]);
311 let src = "use crate::secrets::Key;\npub use std::process::Command;\n";
312 let v = eval(&r, "src/a.rs", src);
313 assert_eq!(v.len(), 1);
314 assert!(v[0].message.contains("crate::secrets"));
315 }
316
317 #[test]
318 fn js_import_and_require() {
319 let r = rule(Language::Js, r"^lodash", &[]);
320 let src = "import _ from \"lodash\";\nconst x = require('lodash/fp');\nimport y from \"react\";\n";
321 assert_eq!(eval(&r, "src/a.js", src).len(), 2);
322 }
323
324 #[test]
325 fn build_errors_on_generic_without_pattern_and_bad_regex() {
326 let mut spec = crate::test_support::spec_yaml(
327 "id: t\nkind: import_gate\npaths: \"**/*\"\nlanguage: generic\nforbid: x\nlevel: error\n",
328 );
329 assert!(build(&spec).unwrap_err().to_string().contains("generic"));
330 spec = crate::test_support::spec_yaml(
331 "id: t\nkind: import_gate\npaths: \"**/*\"\nlanguage: rust\nforbid: \"[\"\nlevel: error\n",
332 );
333 assert!(build(&spec).unwrap_err().to_string().contains("forbid"));
334 }
335}