1use std::io::Read;
42use std::path::Path;
43use std::process::{Command as StdCommand, Stdio};
44use std::time::{Duration, Instant};
45
46use alint_core::template::{PathTokens, render_path};
47use alint_core::{Context, Error, FactValue, Level, Result, Rule, RuleSpec, Scope, Violation};
48use serde::Deserialize;
49
50const DEFAULT_TIMEOUT_SECS: u64 = 30;
54
55const OUTPUT_CAP_BYTES: usize = 16 * 1024;
59
60const POLL_INTERVAL: Duration = Duration::from_millis(10);
65
66#[derive(Debug, Deserialize)]
67#[serde(deny_unknown_fields)]
68struct Options {
69 command: Vec<String>,
70 #[serde(default)]
73 timeout: Option<u64>,
74}
75
76#[derive(Debug)]
77pub struct CommandRule {
78 id: String,
79 level: Level,
80 policy_url: Option<String>,
81 message: Option<String>,
82 scope: Scope,
83 argv: Vec<String>,
84 timeout: Duration,
85}
86
87impl Rule for CommandRule {
88 alint_core::rule_common_impl!();
89
90 fn path_scope(&self) -> Option<&Scope> {
91 Some(&self.scope)
92 }
93 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
94 let mut violations = Vec::new();
95 for entry in ctx.index.files() {
96 if !self.scope.matches(&entry.path, ctx.index) {
97 continue;
98 }
99 let tokens = PathTokens::from_path(&entry.path);
100 let rendered: Vec<String> = self.argv.iter().map(|s| render_path(s, &tokens)).collect();
101 if let Outcome::Fail(msg) = run_one(
102 &rendered,
103 ctx.root,
104 &entry.path,
105 &self.id,
106 self.level,
107 ctx,
108 self.timeout,
109 ) {
110 let final_msg = self.message.clone().unwrap_or(msg);
111 violations.push(Violation::new(final_msg).with_path(entry.path.clone()));
112 }
113 }
114 Ok(violations)
115 }
116}
117
118enum Outcome {
122 Pass,
123 Fail(String),
124}
125
126#[allow(clippy::too_many_arguments)] fn run_one(
128 argv: &[String],
129 root: &Path,
130 rel_path: &Path,
131 rule_id: &str,
132 level: Level,
133 ctx: &Context<'_>,
134 timeout: Duration,
135) -> Outcome {
136 let Some((program, rest)) = argv.split_first() else {
137 return Outcome::Fail("command rule's argv is empty".to_string());
138 };
139
140 let mut cmd = StdCommand::new(program);
141 cmd.args(rest)
142 .current_dir(root)
143 .stdin(Stdio::null())
144 .stdout(Stdio::piped())
145 .stderr(Stdio::piped())
146 .env("ALINT_PATH", rel_path.to_string_lossy().as_ref())
147 .env("ALINT_ROOT", root.to_string_lossy().as_ref())
148 .env("ALINT_RULE_ID", rule_id)
149 .env("ALINT_LEVEL", level.as_str());
150
151 if let Some(vars) = ctx.vars {
152 for (k, v) in vars {
153 cmd.env(format!("ALINT_VAR_{}", k.to_uppercase()), v);
154 }
155 }
156 if let Some(facts) = ctx.facts {
157 for (k, v) in facts.as_map() {
158 cmd.env(format!("ALINT_FACT_{}", k.to_uppercase()), fact_to_env(v));
159 }
160 }
161
162 let mut child = match cmd.spawn() {
163 Ok(c) => c,
164 Err(e) => {
165 return Outcome::Fail(format!(
166 "could not spawn `{}`: {} \
167 (is it on PATH? working dir: {})",
168 program,
169 e,
170 root.display()
171 ));
172 }
173 };
174
175 let start = Instant::now();
176 loop {
177 match child.try_wait() {
178 Ok(Some(status)) => {
179 let stdout_bytes = drain(child.stdout.take());
180 let stderr_bytes = drain(child.stderr.take());
181 if status.success() {
182 return Outcome::Pass;
183 }
184 return Outcome::Fail(format_failure(
185 program,
186 status.code(),
187 &stdout_bytes,
188 &stderr_bytes,
189 ));
190 }
191 Ok(None) => {
192 if start.elapsed() >= timeout {
193 let _ = child.kill();
194 let _ = child.wait();
195 return Outcome::Fail(format!(
196 "`{}` did not exit within {}s (raise `timeout:` on the rule to extend)",
197 program,
198 timeout.as_secs()
199 ));
200 }
201 std::thread::sleep(POLL_INTERVAL);
202 }
203 Err(e) => {
204 let _ = child.kill();
205 let _ = child.wait();
206 return Outcome::Fail(format!("`{program}` wait error: {e}"));
207 }
208 }
209 }
210}
211
212fn drain(pipe: Option<impl Read>) -> Vec<u8> {
216 let Some(mut p) = pipe else {
217 return Vec::new();
218 };
219 let mut buf = Vec::with_capacity(1024);
220 let _ = p
221 .by_ref()
222 .take(OUTPUT_CAP_BYTES as u64)
223 .read_to_end(&mut buf);
224 buf
225}
226
227fn format_failure(program: &str, code: Option<i32>, stdout: &[u8], stderr: &[u8]) -> String {
228 let stdout_s = lossy_trim(stdout);
229 let stderr_s = lossy_trim(stderr);
230 let exit = code.map_or_else(|| "killed by signal".to_string(), |c| format!("exit {c}"));
231 match (stdout_s.is_empty(), stderr_s.is_empty()) {
232 (true, true) => format!("`{program}` failed ({exit}); no output"),
233 (false, true) => format!("`{program}` failed ({exit}):\n{stdout_s}"),
234 (true, false) => format!("`{program}` failed ({exit}):\n{stderr_s}"),
235 (false, false) => format!("`{program}` failed ({exit}):\n{stdout_s}\n{stderr_s}"),
236 }
237}
238
239fn lossy_trim(bytes: &[u8]) -> String {
240 String::from_utf8_lossy(bytes).trim_end().to_string()
241}
242
243fn fact_to_env(v: &FactValue) -> String {
244 match v {
245 FactValue::Bool(b) => b.to_string(),
246 FactValue::Int(i) => i.to_string(),
247 FactValue::String(s) => s.clone(),
248 }
249}
250
251pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
252 let Some(_paths) = &spec.paths else {
253 return Err(Error::rule_config(
254 &spec.id,
255 "command requires a `paths` field",
256 ));
257 };
258 let opts: Options = spec
259 .deserialize_options()
260 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
261 if opts.command.is_empty() {
262 return Err(Error::rule_config(
263 &spec.id,
264 "command rule's `command:` argv must not be empty",
265 ));
266 }
267 if spec.fix.is_some() {
268 return Err(Error::rule_config(
269 &spec.id,
270 "command rules do not support `fix:` blocks in v0.5.x — \
271 wire a paired fix-on-save tool via a separate `command` \
272 rule (or another rule kind) for now",
273 ));
274 }
275 let timeout = Duration::from_secs(opts.timeout.unwrap_or(DEFAULT_TIMEOUT_SECS));
276 Ok(Box::new(CommandRule {
277 id: spec.id.clone(),
278 level: spec.level,
279 policy_url: spec.policy_url.clone(),
280 message: spec.message.clone(),
281 scope: Scope::from_spec(spec)?,
282 argv: opts.command,
283 timeout,
284 }))
285}
286
287#[cfg(all(test, unix))]
294mod tests {
295 use super::*;
296 use alint_core::{FileEntry, FileIndex};
297
298 fn idx(paths: &[&str]) -> FileIndex {
299 FileIndex::from_entries(
300 paths
301 .iter()
302 .map(|p| FileEntry {
303 path: std::path::Path::new(p).into(),
304 is_dir: false,
305 size: 1,
306 })
307 .collect(),
308 )
309 }
310
311 fn rule(argv: Vec<&str>, scope: &str, timeout: Duration) -> CommandRule {
312 CommandRule {
313 id: "t".into(),
314 level: Level::Error,
315 policy_url: None,
316 message: None,
317 scope: Scope::from_patterns(&[scope.to_string()]).unwrap(),
318 argv: argv.into_iter().map(String::from).collect(),
319 timeout,
320 }
321 }
322
323 fn ctx<'a>(root: &'a Path, index: &'a FileIndex) -> Context<'a> {
324 Context {
325 root,
326 index,
327 registry: None,
328 facts: None,
329 vars: None,
330 git_tracked: None,
331 git_blame: None,
332 }
333 }
334
335 #[test]
336 fn pass_on_zero_exit() {
337 let tmp = tempfile::tempdir().unwrap();
338 std::fs::write(tmp.path().join("a.txt"), b"hello").unwrap();
339 let index = idx(&["a.txt"]);
340 let r = rule(
341 vec!["/bin/sh", "-c", "exit 0"],
342 "*.txt",
343 Duration::from_secs(5),
344 );
345 let v = r.evaluate(&ctx(tmp.path(), &index)).unwrap();
346 assert!(v.is_empty(), "unexpected violations: {v:?}");
347 }
348
349 #[test]
350 fn fail_on_nonzero_exit_carries_stderr() {
351 let tmp = tempfile::tempdir().unwrap();
352 std::fs::write(tmp.path().join("a.txt"), b"x").unwrap();
353 let index = idx(&["a.txt"]);
354 let r = rule(
355 vec!["/bin/sh", "-c", "echo problem >&2; exit 7"],
356 "*.txt",
357 Duration::from_secs(5),
358 );
359 let v = r.evaluate(&ctx(tmp.path(), &index)).unwrap();
360 assert_eq!(v.len(), 1);
361 assert_eq!(v[0].path.as_deref(), Some(Path::new("a.txt")));
362 assert!(v[0].message.contains("exit 7"), "msg: {}", v[0].message);
363 assert!(v[0].message.contains("problem"), "msg: {}", v[0].message);
364 }
365
366 #[test]
367 fn path_template_substitutes_in_argv() {
368 let tmp = tempfile::tempdir().unwrap();
369 std::fs::write(tmp.path().join("a.txt"), b"hi").unwrap();
370 let index = idx(&["a.txt"]);
371 let r = rule(
374 vec![
375 "/bin/sh",
376 "-c",
377 "[ \"$1\" = a.txt ] || exit 1",
378 "_",
379 "{path}",
380 ],
381 "*.txt",
382 Duration::from_secs(5),
383 );
384 let v = r.evaluate(&ctx(tmp.path(), &index)).unwrap();
385 assert!(v.is_empty(), "argv substitution failed: {v:?}");
386 }
387
388 #[test]
389 fn timeout_emits_violation() {
390 let tmp = tempfile::tempdir().unwrap();
391 std::fs::write(tmp.path().join("a.txt"), b"x").unwrap();
392 let index = idx(&["a.txt"]);
393 let r = rule(
394 vec!["/bin/sh", "-c", "sleep 5"],
395 "*.txt",
396 Duration::from_millis(150),
397 );
398 let v = r.evaluate(&ctx(tmp.path(), &index)).unwrap();
399 assert_eq!(v.len(), 1);
400 assert!(
401 v[0].message.contains("did not exit"),
402 "msg: {}",
403 v[0].message
404 );
405 }
406
407 #[test]
408 fn unknown_program_produces_spawn_error_violation() {
409 let tmp = tempfile::tempdir().unwrap();
410 std::fs::write(tmp.path().join("a.txt"), b"x").unwrap();
411 let index = idx(&["a.txt"]);
412 let r = rule(
413 vec!["alint-no-such-program-xyzzy"],
414 "*.txt",
415 Duration::from_secs(2),
416 );
417 let v = r.evaluate(&ctx(tmp.path(), &index)).unwrap();
418 assert_eq!(v.len(), 1);
419 assert!(v[0].message.contains("could not spawn"));
420 }
421
422 #[test]
423 fn alint_path_env_set_for_child() {
424 let tmp = tempfile::tempdir().unwrap();
425 std::fs::write(tmp.path().join("a.txt"), b"x").unwrap();
426 let index = idx(&["a.txt"]);
427 let r = rule(
429 vec!["/bin/sh", "-c", "[ \"$ALINT_PATH\" = a.txt ] || exit 1"],
430 "*.txt",
431 Duration::from_secs(5),
432 );
433 let v = r.evaluate(&ctx(tmp.path(), &index)).unwrap();
434 assert!(v.is_empty(), "ALINT_PATH not set: {v:?}");
435 }
436
437 #[test]
438 fn empty_argv_rejected_at_build_time() {
439 let yaml = r#"
440id: t
441kind: command
442level: error
443paths: "*.txt"
444command: []
445"#;
446 let spec: RuleSpec = serde_yaml_ng::from_str(yaml).unwrap();
447 let err = build(&spec).expect_err("empty argv must error");
448 assert!(format!("{err}").contains("argv must not be empty"));
449 }
450
451 #[test]
452 fn missing_paths_rejected_at_build_time() {
453 let yaml = r#"
454id: t
455kind: command
456level: error
457command: ["/bin/true"]
458"#;
459 let spec: RuleSpec = serde_yaml_ng::from_str(yaml).unwrap();
460 let err = build(&spec).expect_err("missing paths must error");
461 assert!(format!("{err}").contains("requires a `paths` field"));
462 }
463
464 #[test]
465 fn fix_block_rejected_at_build_time() {
466 let yaml = r#"
467id: t
468kind: command
469level: error
470paths: "*.txt"
471command: ["/bin/true"]
472fix:
473 file_remove: {}
474"#;
475 let spec: RuleSpec = serde_yaml_ng::from_str(yaml).unwrap();
476 let err = build(&spec).expect_err("fix on command rule must error");
477 assert!(format!("{err}").contains("do not support `fix:`"));
478 }
479
480 #[test]
481 fn scope_filter_narrows() {
482 let tmp = tempfile::tempdir().unwrap();
487 std::fs::create_dir_all(tmp.path().join("pkg")).unwrap();
488 std::fs::create_dir_all(tmp.path().join("other")).unwrap();
489 std::fs::write(tmp.path().join("pkg/marker.lock"), b"").unwrap();
490 std::fs::write(tmp.path().join("pkg/a.txt"), b"x").unwrap();
491 std::fs::write(tmp.path().join("other/a.txt"), b"x").unwrap();
492 let index = idx(&["pkg/marker.lock", "pkg/a.txt", "other/a.txt"]);
493 let yaml = r#"
494id: t
495kind: command
496level: error
497paths: "**/a.txt"
498scope_filter:
499 has_ancestor: marker.lock
500command: ["/bin/sh", "-c", "exit 1"]
501"#;
502 let spec: RuleSpec = serde_yaml_ng::from_str(yaml).unwrap();
503 let r = build(&spec).unwrap();
504 let v = r.evaluate(&ctx(tmp.path(), &index)).unwrap();
505 assert_eq!(v.len(), 1, "only in-scope file should fire: {v:?}");
506 assert_eq!(v[0].path.as_deref(), Some(Path::new("pkg/a.txt")));
507 }
508}