Skip to main content

alint_rules/
shebang_has_executable.rs

1//! `shebang_has_executable` — every file that starts with `#!`
2//! must have the Unix `+x` bit set.
3//!
4//! The inverse of `executable_has_shebang`: catches scripts that
5//! were committed with a shebang but where the executable bit
6//! was never set (or got clobbered by `git add --chmod=-x`,
7//! `cp`, a tarball round-trip, etc.). Running them requires
8//! `bash script.sh` instead of `./script.sh`, which is usually
9//! not the author's intent.
10//!
11//! Non-Unix platforms: rule is a no-op. No fix op — `chmod`
12//! auto-apply is deferred to a later release (see ROADMAP).
13
14use 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// Fields are read only by the `#[cfg(unix)]` evaluate path; on
21// Windows the struct is constructed but never inspected.
22#[cfg_attr(not(unix), allow(dead_code))]
23pub struct ShebangHasExecutableRule {
24    id: String,
25    level: Level,
26    policy_url: Option<String>,
27    message: Option<String>,
28    scope: Scope,
29}
30
31impl Rule for ShebangHasExecutableRule {
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            // Bounded read: only the first 2 bytes (`#!`)
44            // matter to short-circuit non-shebang files; the
45            // metadata check happens after, only on actual
46            // shebang files.
47            let full = ctx.root.join(&entry.path);
48            let Ok(bytes) = read_prefix_n(&full, 2) else {
49                continue;
50            };
51            if !bytes.starts_with(b"#!") {
52                continue;
53            }
54            let Ok(meta) = std::fs::metadata(&full) else {
55                continue;
56            };
57            if meta.permissions().mode() & 0o111 == 0 {
58                let msg = self
59                    .message
60                    .clone()
61                    .unwrap_or_else(|| "shebang script is not marked executable".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, "shebang_has_executable requires a `paths` field")
77    })?;
78    if spec.fix.is_some() {
79        return Err(Error::rule_config(
80            &spec.id,
81            "shebang_has_executable has no fix op — chmod auto-apply is deferred (see ROADMAP)",
82        ));
83    }
84    Ok(Box::new(ShebangHasExecutableRule {
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: shebang_has_executable\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: shebang_has_executable\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_shebang_lacks_exec_bit() {
126        use std::os::unix::fs::PermissionsExt;
127        let spec = spec_yaml(
128            "id: t\n\
129             kind: shebang_has_executable\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"#!/bin/sh\necho hi\n")]);
135        let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
136            .unwrap()
137            .permissions();
138        perms.set_mode(0o644);
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, "shebang without +x must fire");
142    }
143
144    #[cfg(unix)]
145    #[test]
146    fn evaluate_passes_when_shebang_has_exec_bit() {
147        use std::os::unix::fs::PermissionsExt;
148        let spec = spec_yaml(
149            "id: t\n\
150             kind: shebang_has_executable\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(), "shebang with +x should pass: {v:?}");
163    }
164
165    #[cfg(unix)]
166    #[test]
167    fn evaluate_silent_on_non_shebang_files() {
168        use std::os::unix::fs::PermissionsExt;
169        let spec = spec_yaml(
170            "id: t\n\
171             kind: shebang_has_executable\n\
172             paths: \"**/*\"\n\
173             level: warning\n",
174        );
175        let rule = build(&spec).unwrap();
176        let (tmp, idx) = tempdir_with_files(&[("a.txt", b"plain text")]);
177        let mut perms = std::fs::metadata(tmp.path().join("a.txt"))
178            .unwrap()
179            .permissions();
180        perms.set_mode(0o644);
181        std::fs::set_permissions(tmp.path().join("a.txt"), perms).unwrap();
182        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
183        assert!(v.is_empty(), "no shebang means rule no-ops: {v:?}");
184    }
185
186    #[cfg(unix)]
187    #[test]
188    fn scope_filter_narrows() {
189        use std::os::unix::fs::PermissionsExt;
190        // Two scripts with shebang but no +x; only the one
191        // inside a dir with `marker.lock` as ancestor fires.
192        let spec = spec_yaml(
193            "id: t\n\
194             kind: shebang_has_executable\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"#!/bin/sh\necho hi\n"),
204            ("other/a.sh", b"#!/bin/sh\necho 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(0o644);
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}