alint_rules/
shebang_has_executable.rs1use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
15
16#[derive(Debug)]
17#[cfg_attr(not(unix), allow(dead_code))]
20pub struct ShebangHasExecutableRule {
21 id: String,
22 level: Level,
23 policy_url: Option<String>,
24 message: Option<String>,
25 scope: Scope,
26}
27
28impl Rule for ShebangHasExecutableRule {
29 fn id(&self) -> &str {
30 &self.id
31 }
32 fn level(&self) -> Level {
33 self.level
34 }
35 fn policy_url(&self) -> Option<&str> {
36 self.policy_url.as_deref()
37 }
38
39 #[cfg(unix)]
40 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
41 use std::os::unix::fs::PermissionsExt;
42
43 let mut violations = Vec::new();
44 for entry in ctx.index.files() {
45 if !self.scope.matches(&entry.path) {
46 continue;
47 }
48 let full = ctx.root.join(&entry.path);
49 let Ok(bytes) = std::fs::read(&full) else {
50 continue;
51 };
52 if !bytes.starts_with(b"#!") {
53 continue;
54 }
55 let Ok(meta) = std::fs::metadata(&full) else {
56 continue;
57 };
58 if meta.permissions().mode() & 0o111 == 0 {
59 let msg = self
60 .message
61 .clone()
62 .unwrap_or_else(|| "shebang script is not marked executable".to_string());
63 violations.push(Violation::new(msg).with_path(&entry.path));
64 }
65 }
66 Ok(violations)
67 }
68
69 #[cfg(not(unix))]
70 fn evaluate(&self, _ctx: &Context<'_>) -> Result<Vec<Violation>> {
71 Ok(Vec::new())
72 }
73}
74
75pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
76 let paths = spec.paths.as_ref().ok_or_else(|| {
77 Error::rule_config(&spec.id, "shebang_has_executable requires a `paths` field")
78 })?;
79 if spec.fix.is_some() {
80 return Err(Error::rule_config(
81 &spec.id,
82 "shebang_has_executable has no fix op — chmod auto-apply is deferred (see ROADMAP)",
83 ));
84 }
85 Ok(Box::new(ShebangHasExecutableRule {
86 id: spec.id.clone(),
87 level: spec.level,
88 policy_url: spec.policy_url.clone(),
89 message: spec.message.clone(),
90 scope: Scope::from_paths_spec(paths)?,
91 }))
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use crate::test_support::spec_yaml;
98 #[cfg(unix)]
99 use crate::test_support::{ctx, tempdir_with_files};
100
101 #[test]
102 fn build_rejects_missing_paths_field() {
103 let spec = spec_yaml(
104 "id: t\n\
105 kind: shebang_has_executable\n\
106 level: warning\n",
107 );
108 assert!(build(&spec).is_err());
109 }
110
111 #[test]
112 fn build_rejects_fix_block() {
113 let spec = spec_yaml(
114 "id: t\n\
115 kind: shebang_has_executable\n\
116 paths: \"scripts/**\"\n\
117 level: warning\n\
118 fix:\n \
119 file_remove: {}\n",
120 );
121 assert!(build(&spec).is_err());
122 }
123
124 #[cfg(unix)]
125 #[test]
126 fn evaluate_fires_when_shebang_lacks_exec_bit() {
127 use std::os::unix::fs::PermissionsExt;
128 let spec = spec_yaml(
129 "id: t\n\
130 kind: shebang_has_executable\n\
131 paths: \"scripts/**\"\n\
132 level: warning\n",
133 );
134 let rule = build(&spec).unwrap();
135 let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"#!/bin/sh\necho hi\n")]);
136 let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
137 .unwrap()
138 .permissions();
139 perms.set_mode(0o644);
140 std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
141 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
142 assert_eq!(v.len(), 1, "shebang without +x must fire");
143 }
144
145 #[cfg(unix)]
146 #[test]
147 fn evaluate_passes_when_shebang_has_exec_bit() {
148 use std::os::unix::fs::PermissionsExt;
149 let spec = spec_yaml(
150 "id: t\n\
151 kind: shebang_has_executable\n\
152 paths: \"scripts/**\"\n\
153 level: warning\n",
154 );
155 let rule = build(&spec).unwrap();
156 let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"#!/bin/sh\necho hi\n")]);
157 let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
158 .unwrap()
159 .permissions();
160 perms.set_mode(0o755);
161 std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
162 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
163 assert!(v.is_empty(), "shebang with +x should pass: {v:?}");
164 }
165
166 #[cfg(unix)]
167 #[test]
168 fn evaluate_silent_on_non_shebang_files() {
169 use std::os::unix::fs::PermissionsExt;
170 let spec = spec_yaml(
171 "id: t\n\
172 kind: shebang_has_executable\n\
173 paths: \"**/*\"\n\
174 level: warning\n",
175 );
176 let rule = build(&spec).unwrap();
177 let (tmp, idx) = tempdir_with_files(&[("a.txt", b"plain text")]);
178 let mut perms = std::fs::metadata(tmp.path().join("a.txt"))
179 .unwrap()
180 .permissions();
181 perms.set_mode(0o644);
182 std::fs::set_permissions(tmp.path().join("a.txt"), perms).unwrap();
183 let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
184 assert!(v.is_empty(), "no shebang means rule no-ops: {v:?}");
185 }
186}