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