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    alint_core::rule_common_impl!();
39
40    #[cfg(unix)]
41    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
42        use std::os::unix::fs::PermissionsExt;
43
44        let mut violations = Vec::new();
45        for entry in ctx.index.files() {
46            if !self.scope.matches(&entry.path, ctx.index) {
47                continue;
48            }
49            let full = ctx.root.join(&entry.path);
50            let Ok(meta) = std::fs::metadata(&full) else {
51                continue;
52            };
53            let mode = meta.permissions().mode();
54            let is_exec = mode & 0o111 != 0;
55            let passes = is_exec == self.require_exec;
56            if !passes {
57                let msg = self.message.clone().unwrap_or_else(|| {
58                    if self.require_exec {
59                        format!("mode is 0o{mode:o}; +x bit required")
60                    } else {
61                        format!("mode is 0o{mode:o}; +x bit must not be set")
62                    }
63                });
64                violations.push(Violation::new(msg).with_path(entry.path.clone()));
65            }
66        }
67        Ok(violations)
68    }
69
70    #[cfg(not(unix))]
71    fn evaluate(&self, _ctx: &Context<'_>) -> Result<Vec<Violation>> {
72        // Windows has no true executable bit; treat as always-passing
73        // so configs stay portable across platforms.
74        Ok(Vec::new())
75    }
76}
77
78pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
79    let _paths = spec
80        .paths
81        .as_ref()
82        .ok_or_else(|| Error::rule_config(&spec.id, "executable_bit requires a `paths` field"))?;
83    let opts: Options = spec
84        .deserialize_options()
85        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
86    if spec.fix.is_some() {
87        return Err(Error::rule_config(
88            &spec.id,
89            "executable_bit has no fix op — chmod auto-apply is deferred (see ROADMAP)",
90        ));
91    }
92    Ok(Box::new(ExecutableBitRule {
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        require_exec: opts.require,
99    }))
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::test_support::spec_yaml;
106    // ctx + tempdir_with_files are only consumed by the
107    // `#[cfg(unix)]` evaluate-path tests below; importing them
108    // unconditionally trips `unused_imports` on Windows.
109    #[cfg(unix)]
110    use crate::test_support::{ctx, tempdir_with_files};
111
112    #[test]
113    fn build_rejects_missing_paths_field() {
114        let spec = spec_yaml(
115            "id: t\n\
116             kind: executable_bit\n\
117             require: true\n\
118             level: error\n",
119        );
120        assert!(build(&spec).is_err());
121    }
122
123    #[test]
124    fn build_rejects_missing_require() {
125        let spec = spec_yaml(
126            "id: t\n\
127             kind: executable_bit\n\
128             paths: \"scripts/**\"\n\
129             level: error\n",
130        );
131        assert!(build(&spec).is_err());
132    }
133
134    #[test]
135    fn build_rejects_fix_block() {
136        let spec = spec_yaml(
137            "id: t\n\
138             kind: executable_bit\n\
139             paths: \"scripts/**\"\n\
140             require: true\n\
141             level: error\n\
142             fix:\n  \
143               file_remove: {}\n",
144        );
145        assert!(build(&spec).is_err());
146    }
147
148    #[cfg(unix)]
149    #[test]
150    fn evaluate_fires_when_exec_required_but_missing() {
151        use std::os::unix::fs::PermissionsExt;
152        let spec = spec_yaml(
153            "id: t\n\
154             kind: executable_bit\n\
155             paths: \"scripts/**\"\n\
156             require: true\n\
157             level: error\n",
158        );
159        let rule = build(&spec).unwrap();
160        let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"#!/bin/sh\n")]);
161        // Default mode is 0644 — no +x bit.
162        let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
163            .unwrap()
164            .permissions();
165        perms.set_mode(0o644);
166        std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
167        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
168        assert_eq!(v.len(), 1);
169    }
170
171    #[cfg(unix)]
172    #[test]
173    fn evaluate_passes_when_exec_required_and_set() {
174        use std::os::unix::fs::PermissionsExt;
175        let spec = spec_yaml(
176            "id: t\n\
177             kind: executable_bit\n\
178             paths: \"scripts/**\"\n\
179             require: true\n\
180             level: error\n",
181        );
182        let rule = build(&spec).unwrap();
183        let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"#!/bin/sh\n")]);
184        let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
185            .unwrap()
186            .permissions();
187        perms.set_mode(0o755);
188        std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
189        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
190        assert!(v.is_empty(), "0755 should pass require=true: {v:?}");
191    }
192
193    #[cfg(unix)]
194    #[test]
195    fn evaluate_fires_when_exec_forbidden_but_set() {
196        use std::os::unix::fs::PermissionsExt;
197        // require: false → no .md should be executable
198        let spec = spec_yaml(
199            "id: t\n\
200             kind: executable_bit\n\
201             paths: \"**/*.md\"\n\
202             require: false\n\
203             level: warning\n",
204        );
205        let rule = build(&spec).unwrap();
206        let (tmp, idx) = tempdir_with_files(&[("README.md", b"# title\n")]);
207        let mut perms = std::fs::metadata(tmp.path().join("README.md"))
208            .unwrap()
209            .permissions();
210        perms.set_mode(0o755);
211        std::fs::set_permissions(tmp.path().join("README.md"), perms).unwrap();
212        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
213        assert_eq!(v.len(), 1, "0755 markdown should fire require=false");
214    }
215
216    #[cfg(unix)]
217    #[test]
218    fn scope_filter_narrows() {
219        use std::os::unix::fs::PermissionsExt;
220        // Two .md files chmod'd 0755; only the one with
221        // `marker.lock` as ancestor should fire.
222        let spec = spec_yaml(
223            "id: t\n\
224             kind: executable_bit\n\
225             paths: \"**/*.md\"\n\
226             require: false\n\
227             scope_filter:\n  \
228               has_ancestor: marker.lock\n\
229             level: warning\n",
230        );
231        let rule = build(&spec).unwrap();
232        let (tmp, idx) = tempdir_with_files(&[
233            ("pkg/marker.lock", b""),
234            ("pkg/README.md", b"# in"),
235            ("other/README.md", b"# out"),
236        ]);
237        for rel in ["pkg/README.md", "other/README.md"] {
238            let mut perms = std::fs::metadata(tmp.path().join(rel))
239                .unwrap()
240                .permissions();
241            perms.set_mode(0o755);
242            std::fs::set_permissions(tmp.path().join(rel), perms).unwrap();
243        }
244        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
245        assert_eq!(v.len(), 1, "only in-scope file should fire: {v:?}");
246        assert_eq!(
247            v[0].path.as_deref(),
248            Some(std::path::Path::new("pkg/README.md"))
249        );
250    }
251}