alint_rules/
file_shebang.rs1use 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 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}