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