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)]
24pub struct ExecutableBitRule {
25    id: String,
26    level: Level,
27    policy_url: Option<String>,
28    message: Option<String>,
29    scope: Scope,
30    require_exec: bool,
31}
32
33impl Rule for ExecutableBitRule {
34    fn id(&self) -> &str {
35        &self.id
36    }
37    fn level(&self) -> Level {
38        self.level
39    }
40    fn policy_url(&self) -> Option<&str> {
41        self.policy_url.as_deref()
42    }
43
44    #[cfg(unix)]
45    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
46        use std::os::unix::fs::PermissionsExt;
47
48        let mut violations = Vec::new();
49        for entry in ctx.index.files() {
50            if !self.scope.matches(&entry.path) {
51                continue;
52            }
53            let full = ctx.root.join(&entry.path);
54            let Ok(meta) = std::fs::metadata(&full) else {
55                continue;
56            };
57            let mode = meta.permissions().mode();
58            let is_exec = mode & 0o111 != 0;
59            let passes = is_exec == self.require_exec;
60            if !passes {
61                let msg = self.message.clone().unwrap_or_else(|| {
62                    if self.require_exec {
63                        format!("mode is 0o{mode:o}; +x bit required")
64                    } else {
65                        format!("mode is 0o{mode:o}; +x bit must not be set")
66                    }
67                });
68                violations.push(Violation::new(msg).with_path(&entry.path));
69            }
70        }
71        Ok(violations)
72    }
73
74    #[cfg(not(unix))]
75    fn evaluate(&self, _ctx: &Context<'_>) -> Result<Vec<Violation>> {
76        // Windows has no true executable bit; treat as always-passing
77        // so configs stay portable across platforms.
78        Ok(Vec::new())
79    }
80}
81
82pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
83    let paths = spec
84        .paths
85        .as_ref()
86        .ok_or_else(|| Error::rule_config(&spec.id, "executable_bit requires a `paths` field"))?;
87    let opts: Options = spec
88        .deserialize_options()
89        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
90    if spec.fix.is_some() {
91        return Err(Error::rule_config(
92            &spec.id,
93            "executable_bit has no fix op — chmod auto-apply is deferred (see ROADMAP)",
94        ));
95    }
96    Ok(Box::new(ExecutableBitRule {
97        id: spec.id.clone(),
98        level: spec.level,
99        policy_url: spec.policy_url.clone(),
100        message: spec.message.clone(),
101        scope: Scope::from_paths_spec(paths)?,
102        require_exec: opts.require,
103    }))
104}