alint_rules/
executable_bit.rs1use 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 require: bool,
21}
22
23#[derive(Debug)]
24#[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 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 #[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 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 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 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}