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#[derive(Debug)]
17pub struct ShebangHasExecutableRule {
18    id: String,
19    level: Level,
20    policy_url: Option<String>,
21    message: Option<String>,
22    scope: Scope,
23}
24
25impl Rule for ShebangHasExecutableRule {
26    fn id(&self) -> &str {
27        &self.id
28    }
29    fn level(&self) -> Level {
30        self.level
31    }
32    fn policy_url(&self) -> Option<&str> {
33        self.policy_url.as_deref()
34    }
35
36    #[cfg(unix)]
37    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
38        use std::os::unix::fs::PermissionsExt;
39
40        let mut violations = Vec::new();
41        for entry in ctx.index.files() {
42            if !self.scope.matches(&entry.path) {
43                continue;
44            }
45            let full = ctx.root.join(&entry.path);
46            let Ok(bytes) = std::fs::read(&full) else {
47                continue;
48            };
49            if !bytes.starts_with(b"#!") {
50                continue;
51            }
52            let Ok(meta) = std::fs::metadata(&full) else {
53                continue;
54            };
55            if meta.permissions().mode() & 0o111 == 0 {
56                let msg = self
57                    .message
58                    .clone()
59                    .unwrap_or_else(|| "shebang script is not marked executable".to_string());
60                violations.push(Violation::new(msg).with_path(&entry.path));
61            }
62        }
63        Ok(violations)
64    }
65
66    #[cfg(not(unix))]
67    fn evaluate(&self, _ctx: &Context<'_>) -> Result<Vec<Violation>> {
68        Ok(Vec::new())
69    }
70}
71
72pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
73    let paths = spec.paths.as_ref().ok_or_else(|| {
74        Error::rule_config(&spec.id, "shebang_has_executable requires a `paths` field")
75    })?;
76    if spec.fix.is_some() {
77        return Err(Error::rule_config(
78            &spec.id,
79            "shebang_has_executable has no fix op — chmod auto-apply is deferred (see ROADMAP)",
80        ));
81    }
82    Ok(Box::new(ShebangHasExecutableRule {
83        id: spec.id.clone(),
84        level: spec.level,
85        policy_url: spec.policy_url.clone(),
86        message: spec.message.clone(),
87        scope: Scope::from_paths_spec(paths)?,
88    }))
89}