Skip to main content

alint_rules/
file_shebang.rs

1//! `file_shebang` — first line of each file in scope must
2//! match a shebang regex.
3//!
4//! Pairs naturally with [`crate::executable_has_shebang`]
5//! (which checks shebang *presence* on `+x` files) and
6//! [`crate::shebang_has_executable`] (which checks the
7//! reverse, that a shebang file is `+x`). `file_shebang`
8//! checks shebang *shape*: enforce a specific interpreter
9//! pinning, ban `#!/usr/bin/env <foo>` in favour of an
10//! absolute path, require `set -euo pipefail` immediately
11//! after, etc.
12//!
13//! The default `shebang:` regex (when the user omits the
14//! field) is just `^#!`, which only enforces presence.
15//! Most useful configs supply a tighter regex like
16//! `^#!/usr/bin/env bash$` or `^#!/bin/bash -euo pipefail$`.
17
18use std::path::Path;
19
20use alint_core::{
21    Context, Error, Level, PerFileRule, Result, Rule, RuleSpec, Scope, Violation, eval_per_file,
22};
23use regex::Regex;
24use serde::Deserialize;
25
26#[derive(Debug, Deserialize)]
27#[serde(deny_unknown_fields)]
28struct Options {
29    #[serde(default = "default_shebang")]
30    shebang: String,
31}
32
33fn default_shebang() -> String {
34    "^#!".to_string()
35}
36
37#[derive(Debug)]
38pub struct FileShebangRule {
39    id: String,
40    level: Level,
41    policy_url: Option<String>,
42    message: Option<String>,
43    scope: Scope,
44    pattern_src: String,
45    pattern: Regex,
46}
47
48impl Rule for FileShebangRule {
49    alint_core::rule_common_impl!();
50
51    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
52        eval_per_file(self, ctx)
53    }
54
55    fn as_per_file(&self) -> Option<&dyn PerFileRule> {
56        Some(self)
57    }
58}
59
60impl PerFileRule for FileShebangRule {
61    fn path_scope(&self) -> &Scope {
62        &self.scope
63    }
64
65    fn evaluate_file(
66        &self,
67        _ctx: &Context<'_>,
68        path: &Path,
69        bytes: &[u8],
70    ) -> Result<Vec<Violation>> {
71        let first_line = match std::str::from_utf8(bytes) {
72            Ok(text) => text.split('\n').next().unwrap_or(""),
73            Err(_) => "",
74        };
75        if self.pattern.is_match(first_line) {
76            return Ok(Vec::new());
77        }
78        let msg = self.message.clone().unwrap_or_else(|| {
79            format!(
80                "first line {first_line:?} does not match required shebang /{}/",
81                self.pattern_src
82            )
83        });
84        Ok(vec![
85            Violation::new(msg)
86                .with_path(std::sync::Arc::<Path>::from(path))
87                .with_location(1, 1),
88        ])
89    }
90}
91
92pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
93    let Some(_paths) = &spec.paths else {
94        return Err(Error::rule_config(
95            &spec.id,
96            "file_shebang requires a `paths` field",
97        ));
98    };
99    let opts: Options = spec
100        .deserialize_options()
101        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
102    let pattern = Regex::new(&opts.shebang)
103        .map_err(|e| Error::rule_config(&spec.id, format!("invalid shebang regex: {e}")))?;
104    Ok(Box::new(FileShebangRule {
105        id: spec.id.clone(),
106        level: spec.level,
107        policy_url: spec.policy_url.clone(),
108        message: spec.message.clone(),
109        scope: Scope::from_spec(spec)?,
110        pattern_src: opts.shebang,
111        pattern,
112    }))
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::test_support::{ctx, spec_yaml, tempdir_with_files};
119
120    #[test]
121    fn build_rejects_missing_paths_field() {
122        let spec = spec_yaml(
123            "id: t\n\
124             kind: file_shebang\n\
125             shebang: \"^#!/bin/sh\"\n\
126             level: error\n",
127        );
128        assert!(build(&spec).is_err());
129    }
130
131    #[test]
132    fn build_rejects_invalid_regex() {
133        let spec = spec_yaml(
134            "id: t\n\
135             kind: file_shebang\n\
136             paths: \"**/*.sh\"\n\
137             shebang: \"[unterminated\"\n\
138             level: error\n",
139        );
140        assert!(build(&spec).is_err());
141    }
142
143    #[test]
144    fn evaluate_passes_when_shebang_matches() {
145        let spec = spec_yaml(
146            "id: t\n\
147             kind: file_shebang\n\
148             paths: \"**/*.sh\"\n\
149             shebang: \"^#!/(usr/)?bin/(env )?(ba)?sh\"\n\
150             level: error\n",
151        );
152        let rule = build(&spec).unwrap();
153        let (tmp, idx) = tempdir_with_files(&[("a.sh", b"#!/usr/bin/env bash\necho hi\n")]);
154        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
155        assert!(v.is_empty(), "shebang should match: {v:?}");
156    }
157
158    #[test]
159    fn evaluate_fires_when_shebang_missing() {
160        let spec = spec_yaml(
161            "id: t\n\
162             kind: file_shebang\n\
163             paths: \"**/*.sh\"\n\
164             shebang: \"^#!\"\n\
165             level: error\n",
166        );
167        let rule = build(&spec).unwrap();
168        let (tmp, idx) = tempdir_with_files(&[("a.sh", b"echo hi\n")]);
169        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
170        assert_eq!(v.len(), 1);
171    }
172
173    #[test]
174    fn evaluate_only_inspects_first_line() {
175        // A shebang on line 5 must NOT satisfy the rule —
176        // shebangs are line-1-only.
177        let spec = spec_yaml(
178            "id: t\n\
179             kind: file_shebang\n\
180             paths: \"**/*.sh\"\n\
181             shebang: \"^#!/bin/sh\"\n\
182             level: error\n",
183        );
184        let rule = build(&spec).unwrap();
185        let (tmp, idx) = tempdir_with_files(&[("a.sh", b"echo first\n#!/bin/sh\n")]);
186        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
187        assert_eq!(v.len(), 1, "shebang on line 2 shouldn't satisfy");
188    }
189}