Skip to main content

alint_rules/
import_gate.rs

1//! `import_gate` — forbid imports whose extracted target matches
2//! a regex, within a path scope. An architectural import
3//! firewall (k8s `staging/` layering, airflow core/providers,
4//! `torch._C`, prometheus-imports). Matches the **extracted
5//! import target** (not the raw line) and supports `allow`
6//! exemptions — the precise, low-false-positive specialisation
7//! of `file_content_forbidden`. Per-file rule. Design +
8//! open-question resolutions: `docs/design/v0.10/import_gate.md`.
9//!
10//! ```yaml
11//! - id: staging-no-main-module
12//!   kind: import_gate
13//!   paths: "staging/src/k8s.io/**/*.go"
14//!   language: go                          # go|python|rust|js|generic
15//!   forbid: "^k8s\\.io/kubernetes/"       # regex on the EXTRACTED target
16//!   allow: ["staging/src/k8s.io/legacy/**"]
17//!   level: error
18//! ```
19
20use 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    /// No preset — an explicit `import_pattern` is required.
36    Generic,
37}
38
39impl Language {
40    /// Default import-line regex; **capture group 1 is the
41    /// imported target**. Line-based (not a grammar — see the
42    /// design doc's false-positive section); users override with
43    /// `import_pattern` for edge cases.
44    fn default_pattern(self) -> Option<&'static str> {
45        Some(match self {
46            // `import "x"` / `import alias "x"` / `import _ "x"`,
47            // or a grouped-block member line (`\t"x"`, `\t_ "x"`,
48            // `\talias "x"`), end-anchored (optional trailing
49            // line comment) so a mid-statement string can't match.
50            Self::Go => r#"^\s*(?:import\s+)?(?:_\s+|[A-Za-z][\w.]*\s+)?"([^"]+)"\s*(?://.*)?$"#,
51            // `import a.b` or `from a.b import c` -> `a.b`.
52            Self::Python => r"^\s*(?:from|import)\s+([\w.]+)",
53            // `use a::b::c;` / `pub use a::{b, c};` -> `a::b::c` / `a::`.
54            Self::Rust => r"^\s*(?:pub\s+)?use\s+([\w:]+)",
55            // `import x from "m"`, `import "m"`, `require("m")`,
56            // `import("m")` -> `m`.
57            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    /// Explicit import-line regex (capture group 1 = target).
70    /// Overrides the `language` preset.
71    #[serde(default)]
72    import_pattern: Option<String>,
73    /// File globs inside the scope that are exempt from the gate.
74    #[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        // Sanctioned exceptions: a file in scope but on the allow
119        // list is exempt from the gate entirely.
120        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    // Explicit `import_pattern` wins; else the `language` preset;
168    // else (no preset, no pattern) it's a config error.
169    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        // The forbidden path appears only in a comment / string,
274        // not an actual import → no violation.
275        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}