Skip to main content

alint_rules/
executable_bit.rs

1//! `executable_bit` — assert every file in scope either has the
2//! Unix `+x` bit set (`require: true`) or does not (`require: false`).
3//!
4//! Common uses:
5//!   - Force every script under `scripts/` to be executable.
6//!   - Force `.md`, `.txt`, `.yaml` to *not* be executable
7//!     (a frequent accidental commit).
8//!
9//! Windows has no true executable bit; on non-Unix platforms the
10//! rule is a no-op (never produces violations). Document this in
11//! the config so platform-specific behaviour isn't a surprise.
12
13use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
14use serde::Deserialize;
15
16#[derive(Debug, Deserialize)]
17#[serde(deny_unknown_fields)]
18struct Options {
19    /// `true` → +x must be set; `false` → +x must NOT be set.
20    require: bool,
21}
22
23#[derive(Debug)]
24// Fields are read only by the `#[cfg(unix)]` evaluate path; on
25// Windows the struct is constructed but never inspected, so
26// rustc flags `message`/`scope`/`require_exec` as dead code.
27#[cfg_attr(not(unix), allow(dead_code))]
28pub struct ExecutableBitRule {
29    id: String,
30    level: Level,
31    policy_url: Option<String>,
32    message: Option<String>,
33    scope: Scope,
34    require_exec: bool,
35}
36
37impl Rule for ExecutableBitRule {
38    fn id(&self) -> &str {
39        &self.id
40    }
41    fn level(&self) -> Level {
42        self.level
43    }
44    fn policy_url(&self) -> Option<&str> {
45        self.policy_url.as_deref()
46    }
47
48    #[cfg(unix)]
49    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
50        use std::os::unix::fs::PermissionsExt;
51
52        let mut violations = Vec::new();
53        for entry in ctx.index.files() {
54            if !self.scope.matches(&entry.path) {
55                continue;
56            }
57            let full = ctx.root.join(&entry.path);
58            let Ok(meta) = std::fs::metadata(&full) else {
59                continue;
60            };
61            let mode = meta.permissions().mode();
62            let is_exec = mode & 0o111 != 0;
63            let passes = is_exec == self.require_exec;
64            if !passes {
65                let msg = self.message.clone().unwrap_or_else(|| {
66                    if self.require_exec {
67                        format!("mode is 0o{mode:o}; +x bit required")
68                    } else {
69                        format!("mode is 0o{mode:o}; +x bit must not be set")
70                    }
71                });
72                violations.push(Violation::new(msg).with_path(entry.path.clone()));
73            }
74        }
75        Ok(violations)
76    }
77
78    #[cfg(not(unix))]
79    fn evaluate(&self, _ctx: &Context<'_>) -> Result<Vec<Violation>> {
80        // Windows has no true executable bit; treat as always-passing
81        // so configs stay portable across platforms.
82        Ok(Vec::new())
83    }
84}
85
86pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
87    let paths = spec
88        .paths
89        .as_ref()
90        .ok_or_else(|| Error::rule_config(&spec.id, "executable_bit requires a `paths` field"))?;
91    let opts: Options = spec
92        .deserialize_options()
93        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
94    if spec.fix.is_some() {
95        return Err(Error::rule_config(
96            &spec.id,
97            "executable_bit has no fix op — chmod auto-apply is deferred (see ROADMAP)",
98        ));
99    }
100    Ok(Box::new(ExecutableBitRule {
101        id: spec.id.clone(),
102        level: spec.level,
103        policy_url: spec.policy_url.clone(),
104        message: spec.message.clone(),
105        scope: Scope::from_paths_spec(paths)?,
106        require_exec: opts.require,
107    }))
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::test_support::spec_yaml;
114    // ctx + tempdir_with_files are only consumed by the
115    // `#[cfg(unix)]` evaluate-path tests below; importing them
116    // unconditionally trips `unused_imports` on Windows.
117    #[cfg(unix)]
118    use crate::test_support::{ctx, tempdir_with_files};
119
120    #[test]
121    fn build_rejects_missing_paths_field() {
122        let spec = spec_yaml(
123            "id: t\n\
124             kind: executable_bit\n\
125             require: true\n\
126             level: error\n",
127        );
128        assert!(build(&spec).is_err());
129    }
130
131    #[test]
132    fn build_rejects_missing_require() {
133        let spec = spec_yaml(
134            "id: t\n\
135             kind: executable_bit\n\
136             paths: \"scripts/**\"\n\
137             level: error\n",
138        );
139        assert!(build(&spec).is_err());
140    }
141
142    #[test]
143    fn build_rejects_fix_block() {
144        let spec = spec_yaml(
145            "id: t\n\
146             kind: executable_bit\n\
147             paths: \"scripts/**\"\n\
148             require: true\n\
149             level: error\n\
150             fix:\n  \
151               file_remove: {}\n",
152        );
153        assert!(build(&spec).is_err());
154    }
155
156    #[cfg(unix)]
157    #[test]
158    fn evaluate_fires_when_exec_required_but_missing() {
159        use std::os::unix::fs::PermissionsExt;
160        let spec = spec_yaml(
161            "id: t\n\
162             kind: executable_bit\n\
163             paths: \"scripts/**\"\n\
164             require: true\n\
165             level: error\n",
166        );
167        let rule = build(&spec).unwrap();
168        let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"#!/bin/sh\n")]);
169        // Default mode is 0644 — no +x bit.
170        let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
171            .unwrap()
172            .permissions();
173        perms.set_mode(0o644);
174        std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
175        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
176        assert_eq!(v.len(), 1);
177    }
178
179    #[cfg(unix)]
180    #[test]
181    fn evaluate_passes_when_exec_required_and_set() {
182        use std::os::unix::fs::PermissionsExt;
183        let spec = spec_yaml(
184            "id: t\n\
185             kind: executable_bit\n\
186             paths: \"scripts/**\"\n\
187             require: true\n\
188             level: error\n",
189        );
190        let rule = build(&spec).unwrap();
191        let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"#!/bin/sh\n")]);
192        let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
193            .unwrap()
194            .permissions();
195        perms.set_mode(0o755);
196        std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
197        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
198        assert!(v.is_empty(), "0755 should pass require=true: {v:?}");
199    }
200
201    #[cfg(unix)]
202    #[test]
203    fn evaluate_fires_when_exec_forbidden_but_set() {
204        use std::os::unix::fs::PermissionsExt;
205        // require: false → no .md should be executable
206        let spec = spec_yaml(
207            "id: t\n\
208             kind: executable_bit\n\
209             paths: \"**/*.md\"\n\
210             require: false\n\
211             level: warning\n",
212        );
213        let rule = build(&spec).unwrap();
214        let (tmp, idx) = tempdir_with_files(&[("README.md", b"# title\n")]);
215        let mut perms = std::fs::metadata(tmp.path().join("README.md"))
216            .unwrap()
217            .permissions();
218        perms.set_mode(0o755);
219        std::fs::set_permissions(tmp.path().join("README.md"), perms).unwrap();
220        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
221        assert_eq!(v.len(), 1, "0755 markdown should fire require=false");
222    }
223}