1use std::collections::{HashMap, HashSet};
4use std::io::Write;
5use std::process::{Command, Stdio};
6
7use anyhow::{bail, Context};
8use serde::Deserialize;
9
10use crate::parser::{Heredoc, Instruction, InstructionForm, SourcePosition};
11use crate::rules::{Finding, Severity};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Mode {
15 Off,
16 Auto,
17 Required,
18}
19
20impl Mode {
21 pub fn parse(value: Option<&str>) -> anyhow::Result<Self> {
22 match value.unwrap_or("off").to_ascii_lowercase().as_str() {
23 "off" => Ok(Self::Off),
24 "auto" => Ok(Self::Auto),
25 "required" => Ok(Self::Required),
26 value => bail!("Unknown ShellCheck mode '{value}'; expected off, auto, or required"),
27 }
28 }
29}
30
31#[derive(Debug, Deserialize)]
32#[serde(rename_all = "camelCase")]
33struct Diagnostic {
34 line: usize,
35 #[serde(default)]
36 end_line: usize,
37 column: usize,
38 #[serde(default)]
39 end_column: usize,
40 level: String,
41 code: u64,
42 message: String,
43}
44
45struct Script {
46 source: String,
47 line_starts: Vec<SourcePosition>,
48 dialect: &'static str,
49 preamble_lines: usize,
50}
51
52pub fn lint(
55 content: &str,
56 instructions: &[Instruction],
57 mode: Mode,
58 exclude: &[String],
59) -> Vec<Finding> {
60 if mode == Mode::Off {
61 return Vec::new();
62 }
63 let mut findings = Vec::new();
64 let mut dialect = Some("sh");
65 let mut stage_environment = HashMap::<String, HashSet<String>>::new();
66 let mut environment = HashSet::new();
67 let mut current_stage_alias = None;
68 for instruction in instructions {
69 if instruction.instruction == "FROM" {
70 environment = from_stage_environment(instruction, &stage_environment);
71 current_stage_alias = from_alias(instruction);
72 if let Some(alias) = ¤t_stage_alias {
73 stage_environment.insert(alias.clone(), environment.clone());
74 }
75 continue;
76 }
77 if matches!(instruction.instruction.as_str(), "ENV" | "ARG") {
78 environment.extend(declared_names(instruction));
79 if let Some(alias) = ¤t_stage_alias {
80 stage_environment.insert(alias.clone(), environment.clone());
81 }
82 continue;
83 }
84 if instruction.instruction == "SHELL" {
85 dialect = shell_dialect(instruction);
86 continue;
87 }
88 if instruction.instruction != "RUN" || !matches!(instruction.form, InstructionForm::Shell) {
89 continue;
90 }
91 let Some(dialect) = dialect else {
92 continue;
93 };
94 for script in scripts_for_run(content, instruction, dialect, &environment) {
95 match run(&script, exclude) {
96 Ok(mut shellcheck_findings) => findings.append(&mut shellcheck_findings),
97 Err(error) if mode == Mode::Auto && is_not_found(&error) => return findings,
98 Err(error) => findings.push(bridge_error(instruction.line, error)),
99 }
100 }
101 }
102 findings
103}
104
105fn shell_dialect(instruction: &Instruction) -> Option<&'static str> {
106 let InstructionForm::Json(command) = &instruction.form else {
107 return None;
108 };
109 let executable = command.first()?.rsplit('/').next()?.to_ascii_lowercase();
110 match executable.as_str() {
111 "sh" | "ash" => Some("sh"),
112 "bash" => Some("bash"),
113 "dash" => Some("dash"),
114 "ksh" => Some("ksh"),
115 _ => None,
118 }
119}
120
121fn from_alias(instruction: &Instruction) -> Option<String> {
122 let mut tokens = instruction
123 .arguments
124 .split_whitespace()
125 .filter(|token| !token.starts_with("--"));
126 tokens.next()?;
127 (tokens.next()?.eq_ignore_ascii_case("as"))
128 .then(|| tokens.next().map(str::to_ascii_lowercase))
129 .flatten()
130}
131
132fn from_stage_environment(
133 instruction: &Instruction,
134 stage_environment: &HashMap<String, HashSet<String>>,
135) -> HashSet<String> {
136 let image = instruction
137 .arguments
138 .split_whitespace()
139 .find(|token| !token.starts_with("--"));
140 image
141 .and_then(|image| stage_environment.get(&image.to_ascii_lowercase()))
142 .cloned()
143 .unwrap_or_default()
144}
145
146fn declared_names(instruction: &Instruction) -> Vec<String> {
147 let words = &instruction.words;
148 let Some(first) = words.first() else {
149 return Vec::new();
150 };
151 if first.value.contains('=') {
152 return words
153 .iter()
154 .filter_map(|word| word.value.split_once('=').map(|(name, _)| name))
155 .filter(|name| is_shell_name(name))
156 .map(str::to_string)
157 .collect();
158 }
159 if is_shell_name(&first.value) {
160 vec![first.value.clone()]
161 } else {
162 Vec::new()
163 }
164}
165
166fn is_shell_name(name: &str) -> bool {
167 let mut characters = name.chars();
168 matches!(characters.next(), Some(character) if character == '_' || character.is_ascii_alphabetic())
169 && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
170}
171
172fn scripts_for_run(
173 content: &str,
174 instruction: &Instruction,
175 dialect: &'static str,
176 environment: &HashSet<String>,
177) -> Vec<Script> {
178 if is_script_heredoc(content, instruction) {
182 let heredoc = &instruction.heredocs[0];
183 return (!heredoc.content.is_empty())
184 .then(|| Script {
185 source: script_source(&heredoc.content, environment),
186 line_starts: heredoc_line_starts(content, heredoc),
187 dialect,
188 preamble_lines: environment.len(),
189 })
190 .into_iter()
191 .collect();
192 }
193
194 let mut start = instruction
195 .flags
196 .last()
197 .map(|flag| flag.span.end.offset)
198 .unwrap_or(instruction.keyword_span.end.offset);
199 while let Some(character) = content[start..instruction.span.end.offset].chars().next() {
200 if !character.is_whitespace() {
201 break;
202 }
203 start += character.len_utf8();
204 }
205 (start < instruction.span.end.offset)
206 .then(|| Script {
207 source: script_source(&content[start..instruction.span.end.offset], environment),
208 line_starts: source_line_starts(content, start, instruction.span.end.offset),
209 dialect,
210 preamble_lines: environment.len(),
211 })
212 .into_iter()
213 .collect()
214}
215
216fn script_source(source: &str, environment: &HashSet<String>) -> String {
217 let mut names = environment.iter().collect::<Vec<_>>();
218 names.sort_unstable();
219 names
220 .into_iter()
221 .map(|name| format!("export {name}\n"))
222 .collect::<String>()
223 + source
224}
225
226fn is_script_heredoc(content: &str, instruction: &Instruction) -> bool {
227 let Some(marker) = instruction
228 .heredocs
229 .first()
230 .map(|heredoc| heredoc.marker_span)
231 else {
232 return false;
233 };
234 content[marker.end.offset..instruction.arguments_span.end.offset]
235 .trim()
236 .is_empty()
237}
238
239fn heredoc_line_starts(content: &str, heredoc: &Heredoc) -> Vec<SourcePosition> {
240 let raw = heredoc.content_span.text(content);
241 let mut offset = heredoc.content_span.start.offset;
242 raw.split_inclusive('\n')
243 .map(|line| {
244 let stripped_tabs = heredoc
245 .strip_tabs
246 .then(|| line.bytes().take_while(|byte| *byte == b'\t').count())
247 .unwrap_or(0);
248 let start = position_at(content, offset + stripped_tabs);
249 offset += line.len();
250 start
251 })
252 .collect()
253}
254
255fn source_line_starts(content: &str, start: usize, end: usize) -> Vec<SourcePosition> {
256 let mut offset = start;
257 content[start..end]
258 .split_inclusive('\n')
259 .map(|line| {
260 let line_start = position_at(content, offset);
261 offset += line.len();
262 line_start
263 })
264 .collect()
265}
266
267fn position_at(source: &str, offset: usize) -> SourcePosition {
268 let prefix = &source[..offset];
269 let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1;
270 let column = prefix.rsplit('\n').next().unwrap_or_default().len() + 1;
271 SourcePosition {
272 offset,
273 line,
274 column,
275 }
276}
277
278fn run(script: &Script, exclude: &[String]) -> anyhow::Result<Vec<Finding>> {
279 let mut child = Command::new("shellcheck")
280 .args(["--format=json", "--shell", script.dialect])
281 .args(exclude.iter().map(|code| format!("--exclude={code}")))
282 .arg("-")
283 .stdin(Stdio::piped())
284 .stdout(Stdio::piped())
285 .stderr(Stdio::piped())
286 .spawn()
287 .context("Cannot start ShellCheck")?;
288 child
289 .stdin
290 .take()
291 .expect("stdin is piped")
292 .write_all(script.source.as_bytes())?;
293 let output = child
294 .wait_with_output()
295 .context("Cannot read ShellCheck output")?;
296 if !output.status.success() && output.status.code() != Some(1) {
297 bail!(
298 "ShellCheck failed: {}",
299 String::from_utf8_lossy(&output.stderr).trim()
300 );
301 }
302 let diagnostics: Vec<Diagnostic> =
303 serde_json::from_slice(&output.stdout).context("ShellCheck returned invalid JSON")?;
304 Ok(diagnostics
305 .into_iter()
306 .filter_map(|diagnostic| {
307 (diagnostic.line > script.preamble_lines).then(|| {
308 map_diagnostic(diagnostic, &script.line_starts, script.preamble_lines)
309 })
310 })
311 .collect())
312}
313
314fn map_diagnostic(
315 diagnostic: Diagnostic,
316 line_starts: &[SourcePosition],
317 preamble_lines: usize,
318) -> Finding {
319 let shell_line = diagnostic.line.saturating_sub(preamble_lines);
320 let (line, column) = map_position(line_starts, shell_line, diagnostic.column);
321 let end_source_line = diagnostic
322 .end_line
323 .max(diagnostic.line)
324 .saturating_sub(preamble_lines);
325 let (end_line, end_column) = map_position(
326 line_starts,
327 end_source_line,
328 diagnostic.end_column.max(diagnostic.column),
329 );
330 Finding {
331 rule: format!("SC{:04}", diagnostic.code),
332 severity: match diagnostic.level.as_str() {
333 "error" => Severity::Error,
334 "warning" => Severity::Warning,
335 _ => Severity::Info,
336 },
337 line,
338 column,
339 end_line,
340 end_column,
341 message: diagnostic.message,
342 roast: "ShellCheck found a shell-script problem inside this RUN instruction.".to_string(),
343 }
344}
345
346fn map_position(
347 line_starts: &[SourcePosition],
348 shell_line: usize,
349 shell_column: usize,
350) -> (usize, usize) {
351 let start = line_starts
352 .get(shell_line.saturating_sub(1))
353 .copied()
354 .or_else(|| line_starts.last().copied())
355 .unwrap_or(SourcePosition {
356 offset: 0,
357 line: 0,
358 column: 0,
359 });
360 (start.line, start.column + shell_column.saturating_sub(1))
361}
362
363fn bridge_error(line: usize, error: anyhow::Error) -> Finding {
364 Finding {
365 rule: "SC0000".into(),
366 severity: Severity::Error,
367 line,
368 column: 0,
369 end_line: 0,
370 end_column: 0,
371 message: format!("ShellCheck integration failed: {error:#}"),
372 roast: "ShellCheck was required, but the shell-analysis sidecar could not run.".to_string(),
373 }
374}
375
376fn is_not_found(error: &anyhow::Error) -> bool {
377 error.chain().any(|cause| {
378 cause
379 .downcast_ref::<std::io::Error>()
380 .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound)
381 })
382}
383
384#[cfg(test)]
385mod tests {
386 use super::{
387 declared_names, heredoc_line_starts, map_diagnostic, script_source, scripts_for_run,
388 shell_dialect, source_line_starts, Diagnostic,
389 };
390 use crate::parser::parse;
391 use std::collections::HashSet;
392
393 #[test]
394 fn maps_shellcheck_ranges_to_dockerfile_source_positions() {
395 let source = "FROM alpine\nRUN echo $name\n";
396 let finding = map_diagnostic(
397 Diagnostic {
398 line: 1,
399 end_line: 1,
400 column: 6,
401 end_column: 11,
402 level: "warning".into(),
403 code: 2086,
404 message: "Double quote to prevent globbing".into(),
405 },
406 &source_line_starts(source, 16, source.len() - 1),
407 0,
408 );
409 assert_eq!(finding.rule, "SC2086");
410 assert_eq!((finding.line, finding.column), (2, 10));
411 assert_eq!((finding.end_line, finding.end_column), (2, 15));
412 }
413
414 #[test]
415 fn preserves_source_columns_for_tab_stripped_heredocs() {
416 let source = "FROM alpine\nRUN <<-SCRIPT\n\techo $name\nSCRIPT\n";
417 let instruction = &parse(source)[1];
418 let scripts = scripts_for_run(source, instruction, "sh", &HashSet::new());
419 assert_eq!(scripts[0].source, "echo $name\n");
420 let starts = heredoc_line_starts(source, &instruction.heredocs[0]);
421 let finding = map_diagnostic(
422 Diagnostic {
423 line: 1,
424 end_line: 1,
425 column: 6,
426 end_column: 11,
427 level: "warning".into(),
428 code: 2086,
429 message: "Double quote to prevent globbing".into(),
430 },
431 &starts,
432 0,
433 );
434 assert_eq!((finding.line, finding.column), (3, 7));
435 assert_eq!((finding.end_line, finding.end_column), (3, 12));
436 }
437
438 #[test]
439 fn extracts_run_commands_after_buildkit_flags_and_continuations() {
440 let source =
441 "FROM alpine\nRUN --mount=type=cache,target=/cache echo one && \\\n echo $name\n";
442 let instruction = &parse(source)[1];
443 let scripts = scripts_for_run(source, instruction, "sh", &HashSet::new());
444 assert_eq!(scripts.len(), 1);
445 assert_eq!(scripts[0].source, "echo one && \\\n echo $name");
446 assert_eq!(
447 scripts[0]
448 .line_starts
449 .iter()
450 .map(|position| (position.line, position.column))
451 .collect::<Vec<_>>(),
452 [(2, 38), (3, 1)]
453 );
454 let finding = map_diagnostic(
455 Diagnostic {
456 line: 2,
457 end_line: 2,
458 column: 3,
459 end_column: 8,
460 level: "warning".into(),
461 code: 2086,
462 message: "Double quote to prevent globbing".into(),
463 },
464 &scripts[0].line_starts,
465 0,
466 );
467 assert_eq!((finding.line, finding.column), (3, 3));
468 }
469
470 #[test]
471 fn keeps_command_attached_heredocs_as_shell_source() {
472 let source = "FROM alpine\nRUN <<EOF cat > /message\nhello\nEOF\n";
473 let instruction = &parse(source)[1];
474 let scripts = scripts_for_run(source, instruction, "sh", &HashSet::new());
475 assert_eq!(scripts.len(), 1);
476 assert_eq!(scripts[0].source, "<<EOF cat > /message\nhello\nEOF");
477 }
478
479 #[test]
480 fn recognizes_posix_shells_and_skips_non_posix_shells() {
481 let source = "SHELL [\"/bin/bash\", \"-c\"]\nSHELL [\"powershell\", \"-command\"]\n";
482 let instructions = parse(source);
483 assert_eq!(shell_dialect(&instructions[0]), Some("bash"));
484 assert_eq!(shell_dialect(&instructions[1]), None);
485 }
486
487 #[test]
488 fn env_declarations_are_exported_without_changing_source_positions() {
489 let instructions = parse("ENV FIRST=one SECOND=two\nENV THIRD three\n");
490 assert_eq!(declared_names(&instructions[0]), ["FIRST", "SECOND"]);
491 assert_eq!(declared_names(&instructions[1]), ["THIRD"]);
492
493 let environment = HashSet::from(["SECOND".to_string(), "FIRST".to_string()]);
494 assert_eq!(
495 script_source("echo \"$FIRST\"\n", &environment),
496 "export FIRST\nexport SECOND\necho \"$FIRST\"\n"
497 );
498 let finding = map_diagnostic(
499 Diagnostic {
500 line: 3,
501 end_line: 3,
502 column: 7,
503 end_column: 13,
504 level: "warning".into(),
505 code: 2154,
506 message: "FIRST is referenced but not assigned.".into(),
507 },
508 &source_line_starts("RUN echo \"$FIRST\"\n", 4, 18),
509 2,
510 );
511 assert_eq!((finding.line, finding.column), (1, 11));
512 }
513}