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    fn id(&self) -> &str {
33        &self.id
34    }
35    fn level(&self) -> Level {
36        self.level
37    }
38    fn policy_url(&self) -> Option<&str> {
39        self.policy_url.as_deref()
40    }
41
42    #[cfg(unix)]
43    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
44        use std::os::unix::fs::PermissionsExt;
45
46        let mut violations = Vec::new();
47        for entry in ctx.index.files() {
48            if !self.scope.matches(&entry.path, ctx.index) {
49                continue;
50            }
51            // Bounded read: only the first 2 bytes (`#!`)
52            // matter to short-circuit non-shebang files; the
53            // metadata check happens after, only on actual
54            // shebang files.
55            let full = ctx.root.join(&entry.path);
56            let Ok(bytes) = read_prefix_n(&full, 2) else {
57                continue;
58            };
59            if !bytes.starts_with(b"#!") {
60                continue;
61            }
62            let Ok(meta) = std::fs::metadata(&full) else {
63                continue;
64            };
65            if meta.permissions().mode() & 0o111 == 0 {
66                let msg = self
67                    .message
68                    .clone()
69                    .unwrap_or_else(|| "shebang script is not marked executable".to_string());
70                violations.push(Violation::new(msg).with_path(entry.path.clone()));
71            }
72        }
73        Ok(violations)
74    }
75
76    #[cfg(not(unix))]
77    fn evaluate(&self, _ctx: &Context<'_>) -> Result<Vec<Violation>> {
78        Ok(Vec::new())
79    }
80}
81
82pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
83    let _paths = spec.paths.as_ref().ok_or_else(|| {
84        Error::rule_config(&spec.id, "shebang_has_executable requires a `paths` field")
85    })?;
86    if spec.fix.is_some() {
87        return Err(Error::rule_config(
88            &spec.id,
89            "shebang_has_executable has no fix op — chmod auto-apply is deferred (see ROADMAP)",
90        ));
91    }
92    Ok(Box::new(ShebangHasExecutableRule {
93        id: spec.id.clone(),
94        level: spec.level,
95        policy_url: spec.policy_url.clone(),
96        message: spec.message.clone(),
97        scope: Scope::from_spec(spec)?,
98    }))
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::test_support::spec_yaml;
105    #[cfg(unix)]
106    use crate::test_support::{ctx, tempdir_with_files};
107
108    #[test]
109    fn build_rejects_missing_paths_field() {
110        let spec = spec_yaml(
111            "id: t\n\
112             kind: shebang_has_executable\n\
113             level: warning\n",
114        );
115        assert!(build(&spec).is_err());
116    }
117
118    #[test]
119    fn build_rejects_fix_block() {
120        let spec = spec_yaml(
121            "id: t\n\
122             kind: shebang_has_executable\n\
123             paths: \"scripts/**\"\n\
124             level: warning\n\
125             fix:\n  \
126               file_remove: {}\n",
127        );
128        assert!(build(&spec).is_err());
129    }
130
131    #[cfg(unix)]
132    #[test]
133    fn evaluate_fires_when_shebang_lacks_exec_bit() {
134        use std::os::unix::fs::PermissionsExt;
135        let spec = spec_yaml(
136            "id: t\n\
137             kind: shebang_has_executable\n\
138             paths: \"scripts/**\"\n\
139             level: warning\n",
140        );
141        let rule = build(&spec).unwrap();
142        let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"#!/bin/sh\necho hi\n")]);
143        let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
144            .unwrap()
145            .permissions();
146        perms.set_mode(0o644);
147        std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
148        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
149        assert_eq!(v.len(), 1, "shebang without +x must fire");
150    }
151
152    #[cfg(unix)]
153    #[test]
154    fn evaluate_passes_when_shebang_has_exec_bit() {
155        use std::os::unix::fs::PermissionsExt;
156        let spec = spec_yaml(
157            "id: t\n\
158             kind: shebang_has_executable\n\
159             paths: \"scripts/**\"\n\
160             level: warning\n",
161        );
162        let rule = build(&spec).unwrap();
163        let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"#!/bin/sh\necho hi\n")]);
164        let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
165            .unwrap()
166            .permissions();
167        perms.set_mode(0o755);
168        std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
169        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
170        assert!(v.is_empty(), "shebang with +x should pass: {v:?}");
171    }
172
173    #[cfg(unix)]
174    #[test]
175    fn evaluate_silent_on_non_shebang_files() {
176        use std::os::unix::fs::PermissionsExt;
177        let spec = spec_yaml(
178            "id: t\n\
179             kind: shebang_has_executable\n\
180             paths: \"**/*\"\n\
181             level: warning\n",
182        );
183        let rule = build(&spec).unwrap();
184        let (tmp, idx) = tempdir_with_files(&[("a.txt", b"plain text")]);
185        let mut perms = std::fs::metadata(tmp.path().join("a.txt"))
186            .unwrap()
187            .permissions();
188        perms.set_mode(0o644);
189        std::fs::set_permissions(tmp.path().join("a.txt"), perms).unwrap();
190        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
191        assert!(v.is_empty(), "no shebang means rule no-ops: {v:?}");
192    }
193
194    #[cfg(unix)]
195    #[test]
196    fn scope_filter_narrows() {
197        use std::os::unix::fs::PermissionsExt;
198        // Two scripts with shebang but no +x; only the one
199        // inside a dir with `marker.lock` as ancestor fires.
200        let spec = spec_yaml(
201            "id: t\n\
202             kind: shebang_has_executable\n\
203             paths: \"**/*.sh\"\n\
204             scope_filter:\n  \
205               has_ancestor: marker.lock\n\
206             level: warning\n",
207        );
208        let rule = build(&spec).unwrap();
209        let (tmp, idx) = tempdir_with_files(&[
210            ("pkg/marker.lock", b""),
211            ("pkg/a.sh", b"#!/bin/sh\necho hi\n"),
212            ("other/a.sh", b"#!/bin/sh\necho hi\n"),
213        ]);
214        for rel in ["pkg/a.sh", "other/a.sh"] {
215            let mut perms = std::fs::metadata(tmp.path().join(rel))
216                .unwrap()
217                .permissions();
218            perms.set_mode(0o644);
219            std::fs::set_permissions(tmp.path().join(rel), perms).unwrap();
220        }
221        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
222        assert_eq!(v.len(), 1, "only in-scope file should fire: {v:?}");
223        assert_eq!(v[0].path.as_deref(), Some(std::path::Path::new("pkg/a.sh")));
224    }
225}