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