alint_rules/
executable_has_shebang.rs1use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
15
16#[cfg(unix)]
17use crate::io::read_prefix_n;
18
19#[derive(Debug)]
20#[cfg_attr(not(unix), allow(dead_code))]
23pub struct ExecutableHasShebangRule {
24 id: String,
25 level: Level,
26 policy_url: Option<String>,
27 message: Option<String>,
28 scope: Scope,
29}
30
31impl Rule for ExecutableHasShebangRule {
32 alint_core::rule_common_impl!();
33
34 #[cfg(unix)]
35 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
36 use std::os::unix::fs::PermissionsExt;
37
38 let mut violations = Vec::new();
39 for entry in ctx.index.files() {
40 if !self.scope.matches(&entry.path, ctx.index) {
41 continue;
42 }
43 let full = ctx.root.join(&entry.path);
44 let Ok(meta) = std::fs::metadata(&full) else {
45 continue;
46 };
47 if meta.permissions().mode() & 0o111 == 0 {
48 continue;
49 }
50 let Ok(bytes) = read_prefix_n(&full, 2) else {
55 continue;
56 };
57 if !bytes.starts_with(b"#!") {
58 let msg = self
59 .message
60 .clone()
61 .unwrap_or_else(|| "executable file has no shebang (#!)".to_string());
62 violations.push(Violation::new(msg).with_path(entry.path.clone()));
63 }
64 }
65 Ok(violations)
66 }
67
68 #[cfg(not(unix))]
69 fn evaluate(&self, _ctx: &Context<'_>) -> Result<Vec<Violation>> {
70 Ok(Vec::new())
71 }
72}
73
74pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
75 let _paths = spec.paths.as_ref().ok_or_else(|| {
76 Error::rule_config(&spec.id, "executable_has_shebang requires a `paths` field")
77 })?;
78 if spec.fix.is_some() {
79 return Err(Error::rule_config(
80 &spec.id,
81 "executable_has_shebang has no fix op — add a shebang or clear +x is a human call",
82 ));
83 }
84 Ok(Box::new(ExecutableHasShebangRule {
85 id: spec.id.clone(),
86 level: spec.level,
87 policy_url: spec.policy_url.clone(),
88 message: spec.message.clone(),
89 scope: Scope::from_spec(spec)?,
90 }))
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96 use crate::test_support::spec_yaml;
97 #[cfg(unix)]
98 use crate::test_support::{ctx, tempdir_with_files};
99
100 #[test]
101 fn build_rejects_missing_paths_field() {
102 let spec = spec_yaml(
103 "id: t\n\
104 kind: executable_has_shebang\n\
105 level: warning\n",
106 );
107 assert!(build(&spec).is_err());
108 }
109
110 #[test]
111 fn build_rejects_fix_block() {
112 let spec = spec_yaml(
113 "id: t\n\
114 kind: executable_has_shebang\n\
115 paths: \"scripts/**\"\n\
116 level: warning\n\
117 fix:\n \
118 file_remove: {}\n",
119 );
120 assert!(build(&spec).is_err());
121 }
122
123 #[cfg(unix)]
124 #[test]
125 fn evaluate_fires_when_exec_lacks_shebang() {
126 use std::os::unix::fs::PermissionsExt;
127 let spec = spec_yaml(
128 "id: t\n\
129 kind: executable_has_shebang\n\
130 paths: \"scripts/**\"\n\
131 level: warning\n",
132 );
133 let rule = build(&spec).unwrap();
134 let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"echo hi\n")]);
135 let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
136 .unwrap()
137 .permissions();
138 perms.set_mode(0o755);
139 std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
140 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
141 assert_eq!(v.len(), 1, "exec without shebang must fire");
142 }
143
144 #[cfg(unix)]
145 #[test]
146 fn evaluate_passes_when_exec_has_shebang() {
147 use std::os::unix::fs::PermissionsExt;
148 let spec = spec_yaml(
149 "id: t\n\
150 kind: executable_has_shebang\n\
151 paths: \"scripts/**\"\n\
152 level: warning\n",
153 );
154 let rule = build(&spec).unwrap();
155 let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"#!/bin/sh\necho hi\n")]);
156 let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
157 .unwrap()
158 .permissions();
159 perms.set_mode(0o755);
160 std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
161 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
162 assert!(v.is_empty(), "exec with shebang should pass: {v:?}");
163 }
164
165 #[cfg(unix)]
166 #[test]
167 fn evaluate_silent_on_non_exec_files() {
168 use std::os::unix::fs::PermissionsExt;
169 let spec = spec_yaml(
170 "id: t\n\
171 kind: executable_has_shebang\n\
172 paths: \"**/*\"\n\
173 level: warning\n",
174 );
175 let rule = build(&spec).unwrap();
176 let (tmp, idx) = tempdir_with_files(&[("README.md", b"# title\n")]);
177 let mut perms = std::fs::metadata(tmp.path().join("README.md"))
178 .unwrap()
179 .permissions();
180 perms.set_mode(0o644);
181 std::fs::set_permissions(tmp.path().join("README.md"), perms).unwrap();
182 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
183 assert!(v.is_empty(), "non-exec doesn't need shebang: {v:?}");
184 }
185
186 #[cfg(unix)]
187 #[test]
188 fn scope_filter_narrows() {
189 use std::os::unix::fs::PermissionsExt;
190 let spec = spec_yaml(
193 "id: t\n\
194 kind: executable_has_shebang\n\
195 paths: \"**/*.sh\"\n\
196 scope_filter:\n \
197 has_ancestor: marker.lock\n\
198 level: warning\n",
199 );
200 let rule = build(&spec).unwrap();
201 let (tmp, idx) = tempdir_with_files(&[
202 ("pkg/marker.lock", b""),
203 ("pkg/a.sh", b"echo hi\n"),
204 ("other/a.sh", b"echo hi\n"),
205 ]);
206 for rel in ["pkg/a.sh", "other/a.sh"] {
207 let mut perms = std::fs::metadata(tmp.path().join(rel))
208 .unwrap()
209 .permissions();
210 perms.set_mode(0o755);
211 std::fs::set_permissions(tmp.path().join(rel), perms).unwrap();
212 }
213 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
214 assert_eq!(v.len(), 1, "only in-scope file should fire: {v:?}");
215 assert_eq!(v[0].path.as_deref(), Some(std::path::Path::new("pkg/a.sh")));
216 }
217}