alint_rules/
file_shebang.rs1use std::path::Path;
19
20use alint_core::{Context, Error, Level, PerFileRule, Result, Rule, RuleSpec, Scope, Violation};
21use regex::Regex;
22use serde::Deserialize;
23
24#[derive(Debug, Deserialize)]
25struct Options {
26 #[serde(default = "default_shebang")]
27 shebang: String,
28}
29
30fn default_shebang() -> String {
31 "^#!".to_string()
32}
33
34#[derive(Debug)]
35pub struct FileShebangRule {
36 id: String,
37 level: Level,
38 policy_url: Option<String>,
39 message: Option<String>,
40 scope: Scope,
41 pattern_src: String,
42 pattern: Regex,
43}
44
45impl Rule for FileShebangRule {
46 fn id(&self) -> &str {
47 &self.id
48 }
49 fn level(&self) -> Level {
50 self.level
51 }
52 fn policy_url(&self) -> Option<&str> {
53 self.policy_url.as_deref()
54 }
55
56 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
57 let mut violations = Vec::new();
58 for entry in ctx.index.files() {
59 if !self.scope.matches(&entry.path, ctx.index) {
60 continue;
61 }
62 let full = ctx.root.join(&entry.path);
63 let Ok(bytes) = std::fs::read(&full) else {
64 continue;
65 };
66 violations.extend(self.evaluate_file(ctx, &entry.path, &bytes)?);
67 }
68 Ok(violations)
69 }
70
71 fn as_per_file(&self) -> Option<&dyn PerFileRule> {
72 Some(self)
73 }
74}
75
76impl PerFileRule for FileShebangRule {
77 fn path_scope(&self) -> &Scope {
78 &self.scope
79 }
80
81 fn evaluate_file(
82 &self,
83 _ctx: &Context<'_>,
84 path: &Path,
85 bytes: &[u8],
86 ) -> Result<Vec<Violation>> {
87 let first_line = match std::str::from_utf8(bytes) {
88 Ok(text) => text.split('\n').next().unwrap_or(""),
89 Err(_) => "",
90 };
91 if self.pattern.is_match(first_line) {
92 return Ok(Vec::new());
93 }
94 let msg = self.message.clone().unwrap_or_else(|| {
95 format!(
96 "first line {first_line:?} does not match required shebang /{}/",
97 self.pattern_src
98 )
99 });
100 Ok(vec![
101 Violation::new(msg)
102 .with_path(std::sync::Arc::<Path>::from(path))
103 .with_location(1, 1),
104 ])
105 }
106}
107
108pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
109 let Some(_paths) = &spec.paths else {
110 return Err(Error::rule_config(
111 &spec.id,
112 "file_shebang requires a `paths` field",
113 ));
114 };
115 let opts: Options = spec
116 .deserialize_options()
117 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
118 let pattern = Regex::new(&opts.shebang)
119 .map_err(|e| Error::rule_config(&spec.id, format!("invalid shebang regex: {e}")))?;
120 Ok(Box::new(FileShebangRule {
121 id: spec.id.clone(),
122 level: spec.level,
123 policy_url: spec.policy_url.clone(),
124 message: spec.message.clone(),
125 scope: Scope::from_spec(spec)?,
126 pattern_src: opts.shebang,
127 pattern,
128 }))
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use crate::test_support::{ctx, spec_yaml, tempdir_with_files};
135
136 #[test]
137 fn build_rejects_missing_paths_field() {
138 let spec = spec_yaml(
139 "id: t\n\
140 kind: file_shebang\n\
141 shebang: \"^#!/bin/sh\"\n\
142 level: error\n",
143 );
144 assert!(build(&spec).is_err());
145 }
146
147 #[test]
148 fn build_rejects_invalid_regex() {
149 let spec = spec_yaml(
150 "id: t\n\
151 kind: file_shebang\n\
152 paths: \"**/*.sh\"\n\
153 shebang: \"[unterminated\"\n\
154 level: error\n",
155 );
156 assert!(build(&spec).is_err());
157 }
158
159 #[test]
160 fn evaluate_passes_when_shebang_matches() {
161 let spec = spec_yaml(
162 "id: t\n\
163 kind: file_shebang\n\
164 paths: \"**/*.sh\"\n\
165 shebang: \"^#!/(usr/)?bin/(env )?(ba)?sh\"\n\
166 level: error\n",
167 );
168 let rule = build(&spec).unwrap();
169 let (tmp, idx) = tempdir_with_files(&[("a.sh", b"#!/usr/bin/env bash\necho hi\n")]);
170 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
171 assert!(v.is_empty(), "shebang should match: {v:?}");
172 }
173
174 #[test]
175 fn evaluate_fires_when_shebang_missing() {
176 let spec = spec_yaml(
177 "id: t\n\
178 kind: file_shebang\n\
179 paths: \"**/*.sh\"\n\
180 shebang: \"^#!\"\n\
181 level: error\n",
182 );
183 let rule = build(&spec).unwrap();
184 let (tmp, idx) = tempdir_with_files(&[("a.sh", b"echo hi\n")]);
185 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
186 assert_eq!(v.len(), 1);
187 }
188
189 #[test]
190 fn evaluate_only_inspects_first_line() {
191 let spec = spec_yaml(
194 "id: t\n\
195 kind: file_shebang\n\
196 paths: \"**/*.sh\"\n\
197 shebang: \"^#!/bin/sh\"\n\
198 level: error\n",
199 );
200 let rule = build(&spec).unwrap();
201 let (tmp, idx) = tempdir_with_files(&[("a.sh", b"echo first\n#!/bin/sh\n")]);
202 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
203 assert_eq!(v.len(), 1, "shebang on line 2 shouldn't satisfy");
204 }
205}