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