1use std::io::Write;
4use std::process::{Command, Stdio};
5
6use anyhow::{bail, Context};
7use serde::Deserialize;
8
9use crate::parser::{Heredoc, Instruction, InstructionForm, SourcePosition};
10use crate::rules::{Finding, Severity};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Mode {
14 Off,
15 Auto,
16 Required,
17}
18
19impl Mode {
20 pub fn parse(value: Option<&str>) -> anyhow::Result<Self> {
21 match value.unwrap_or("off").to_ascii_lowercase().as_str() {
22 "off" => Ok(Self::Off),
23 "auto" => Ok(Self::Auto),
24 "required" => Ok(Self::Required),
25 value => bail!("Unknown ShellCheck mode '{value}'; expected off, auto, or required"),
26 }
27 }
28}
29
30#[derive(Debug, Deserialize)]
31#[serde(rename_all = "camelCase")]
32struct Diagnostic {
33 line: usize,
34 #[serde(default)]
35 end_line: usize,
36 column: usize,
37 #[serde(default)]
38 end_column: usize,
39 level: String,
40 code: u64,
41 message: String,
42}
43
44struct Script<'a> {
45 source: &'a str,
46 line_starts: Vec<SourcePosition>,
47 dialect: &'static str,
48}
49
50pub fn lint(
53 content: &str,
54 instructions: &[Instruction],
55 mode: Mode,
56 exclude: &[String],
57) -> Vec<Finding> {
58 if mode == Mode::Off {
59 return Vec::new();
60 }
61 let mut findings = Vec::new();
62 let mut dialect = Some("sh");
63 for instruction in instructions {
64 if instruction.instruction == "SHELL" {
65 dialect = shell_dialect(instruction);
66 continue;
67 }
68 if instruction.instruction != "RUN" || !matches!(instruction.form, InstructionForm::Shell) {
69 continue;
70 }
71 let Some(dialect) = dialect else {
72 continue;
73 };
74 for script in scripts_for_run(content, instruction, dialect) {
75 match run(&script, exclude) {
76 Ok(mut shellcheck_findings) => findings.append(&mut shellcheck_findings),
77 Err(error) if mode == Mode::Auto && is_not_found(&error) => return findings,
78 Err(error) => findings.push(bridge_error(instruction.line, error)),
79 }
80 }
81 }
82 findings
83}
84
85fn shell_dialect(instruction: &Instruction) -> Option<&'static str> {
86 let InstructionForm::Json(command) = &instruction.form else {
87 return None;
88 };
89 let executable = command.first()?.rsplit('/').next()?.to_ascii_lowercase();
90 match executable.as_str() {
91 "sh" | "ash" => Some("sh"),
92 "bash" => Some("bash"),
93 "dash" => Some("dash"),
94 "ksh" => Some("ksh"),
95 _ => None,
98 }
99}
100
101fn scripts_for_run<'a>(
102 content: &'a str,
103 instruction: &'a Instruction,
104 dialect: &'static str,
105) -> Vec<Script<'a>> {
106 if is_script_heredoc(content, instruction) {
110 let heredoc = &instruction.heredocs[0];
111 return (!heredoc.content.is_empty())
112 .then(|| Script {
113 source: &heredoc.content,
114 line_starts: heredoc_line_starts(content, heredoc),
115 dialect,
116 })
117 .into_iter()
118 .collect();
119 }
120
121 let mut start = instruction
122 .flags
123 .last()
124 .map(|flag| flag.span.end.offset)
125 .unwrap_or(instruction.keyword_span.end.offset);
126 while let Some(character) = content[start..instruction.span.end.offset].chars().next() {
127 if !character.is_whitespace() {
128 break;
129 }
130 start += character.len_utf8();
131 }
132 (start < instruction.span.end.offset)
133 .then(|| Script {
134 source: &content[start..instruction.span.end.offset],
135 line_starts: source_line_starts(content, start, instruction.span.end.offset),
136 dialect,
137 })
138 .into_iter()
139 .collect()
140}
141
142fn is_script_heredoc(content: &str, instruction: &Instruction) -> bool {
143 let Some(marker) = instruction
144 .heredocs
145 .first()
146 .map(|heredoc| heredoc.marker_span)
147 else {
148 return false;
149 };
150 content[marker.end.offset..instruction.arguments_span.end.offset]
151 .trim()
152 .is_empty()
153}
154
155fn heredoc_line_starts(content: &str, heredoc: &Heredoc) -> Vec<SourcePosition> {
156 let raw = heredoc.content_span.text(content);
157 let mut offset = heredoc.content_span.start.offset;
158 raw.split_inclusive('\n')
159 .map(|line| {
160 let stripped_tabs = heredoc
161 .strip_tabs
162 .then(|| line.bytes().take_while(|byte| *byte == b'\t').count())
163 .unwrap_or(0);
164 let start = position_at(content, offset + stripped_tabs);
165 offset += line.len();
166 start
167 })
168 .collect()
169}
170
171fn source_line_starts(content: &str, start: usize, end: usize) -> Vec<SourcePosition> {
172 let mut offset = start;
173 content[start..end]
174 .split_inclusive('\n')
175 .map(|line| {
176 let line_start = position_at(content, offset);
177 offset += line.len();
178 line_start
179 })
180 .collect()
181}
182
183fn position_at(source: &str, offset: usize) -> SourcePosition {
184 let prefix = &source[..offset];
185 let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1;
186 let column = prefix.rsplit('\n').next().unwrap_or_default().len() + 1;
187 SourcePosition {
188 offset,
189 line,
190 column,
191 }
192}
193
194fn run(script: &Script<'_>, exclude: &[String]) -> anyhow::Result<Vec<Finding>> {
195 let mut child = Command::new("shellcheck")
196 .args(["--format=json", "--shell", script.dialect])
197 .args(exclude.iter().map(|code| format!("--exclude={code}")))
198 .arg("-")
199 .stdin(Stdio::piped())
200 .stdout(Stdio::piped())
201 .stderr(Stdio::piped())
202 .spawn()
203 .context("Cannot start ShellCheck")?;
204 child
205 .stdin
206 .take()
207 .expect("stdin is piped")
208 .write_all(script.source.as_bytes())?;
209 let output = child
210 .wait_with_output()
211 .context("Cannot read ShellCheck output")?;
212 if !output.status.success() && output.status.code() != Some(1) {
213 bail!(
214 "ShellCheck failed: {}",
215 String::from_utf8_lossy(&output.stderr).trim()
216 );
217 }
218 let diagnostics: Vec<Diagnostic> =
219 serde_json::from_slice(&output.stdout).context("ShellCheck returned invalid JSON")?;
220 Ok(diagnostics
221 .into_iter()
222 .map(|diagnostic| map_diagnostic(diagnostic, &script.line_starts))
223 .collect())
224}
225
226fn map_diagnostic(diagnostic: Diagnostic, line_starts: &[SourcePosition]) -> Finding {
227 let (line, column) = map_position(line_starts, diagnostic.line, diagnostic.column);
228 let end_source_line = diagnostic.end_line.max(diagnostic.line);
229 let (end_line, end_column) = map_position(
230 line_starts,
231 end_source_line,
232 diagnostic.end_column.max(diagnostic.column),
233 );
234 Finding {
235 rule: format!("SC{:04}", diagnostic.code),
236 severity: match diagnostic.level.as_str() {
237 "error" => Severity::Error,
238 "warning" => Severity::Warning,
239 _ => Severity::Info,
240 },
241 line,
242 column,
243 end_line,
244 end_column,
245 message: diagnostic.message,
246 roast: "ShellCheck found a shell-script problem inside this RUN instruction.".to_string(),
247 }
248}
249
250fn map_position(
251 line_starts: &[SourcePosition],
252 shell_line: usize,
253 shell_column: usize,
254) -> (usize, usize) {
255 let start = line_starts
256 .get(shell_line.saturating_sub(1))
257 .copied()
258 .or_else(|| line_starts.last().copied())
259 .unwrap_or(SourcePosition {
260 offset: 0,
261 line: 0,
262 column: 0,
263 });
264 (start.line, start.column + shell_column.saturating_sub(1))
265}
266
267fn bridge_error(line: usize, error: anyhow::Error) -> Finding {
268 Finding {
269 rule: "SC0000".into(),
270 severity: Severity::Error,
271 line,
272 column: 0,
273 end_line: 0,
274 end_column: 0,
275 message: format!("ShellCheck integration failed: {error:#}"),
276 roast: "ShellCheck was required, but the shell-analysis sidecar could not run.".to_string(),
277 }
278}
279
280fn is_not_found(error: &anyhow::Error) -> bool {
281 error.chain().any(|cause| {
282 cause
283 .downcast_ref::<std::io::Error>()
284 .is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound)
285 })
286}
287
288#[cfg(test)]
289mod tests {
290 use super::{
291 heredoc_line_starts, map_diagnostic, scripts_for_run, shell_dialect, source_line_starts,
292 Diagnostic,
293 };
294 use crate::parser::parse;
295
296 #[test]
297 fn maps_shellcheck_ranges_to_dockerfile_source_positions() {
298 let source = "FROM alpine\nRUN echo $name\n";
299 let finding = map_diagnostic(
300 Diagnostic {
301 line: 1,
302 end_line: 1,
303 column: 6,
304 end_column: 11,
305 level: "warning".into(),
306 code: 2086,
307 message: "Double quote to prevent globbing".into(),
308 },
309 &source_line_starts(source, 16, source.len() - 1),
310 );
311 assert_eq!(finding.rule, "SC2086");
312 assert_eq!((finding.line, finding.column), (2, 10));
313 assert_eq!((finding.end_line, finding.end_column), (2, 15));
314 }
315
316 #[test]
317 fn preserves_source_columns_for_tab_stripped_heredocs() {
318 let source = "FROM alpine\nRUN <<-SCRIPT\n\techo $name\nSCRIPT\n";
319 let instruction = &parse(source)[1];
320 let scripts = scripts_for_run(source, instruction, "sh");
321 assert_eq!(scripts[0].source, "echo $name\n");
322 let starts = heredoc_line_starts(source, &instruction.heredocs[0]);
323 let finding = map_diagnostic(
324 Diagnostic {
325 line: 1,
326 end_line: 1,
327 column: 6,
328 end_column: 11,
329 level: "warning".into(),
330 code: 2086,
331 message: "Double quote to prevent globbing".into(),
332 },
333 &starts,
334 );
335 assert_eq!((finding.line, finding.column), (3, 7));
336 assert_eq!((finding.end_line, finding.end_column), (3, 12));
337 }
338
339 #[test]
340 fn extracts_run_commands_after_buildkit_flags_and_continuations() {
341 let source =
342 "FROM alpine\nRUN --mount=type=cache,target=/cache echo one && \\\n echo $name\n";
343 let instruction = &parse(source)[1];
344 let scripts = scripts_for_run(source, instruction, "sh");
345 assert_eq!(scripts.len(), 1);
346 assert_eq!(scripts[0].source, "echo one && \\\n echo $name");
347 assert_eq!(
348 scripts[0]
349 .line_starts
350 .iter()
351 .map(|position| (position.line, position.column))
352 .collect::<Vec<_>>(),
353 [(2, 38), (3, 1)]
354 );
355 let finding = map_diagnostic(
356 Diagnostic {
357 line: 2,
358 end_line: 2,
359 column: 3,
360 end_column: 8,
361 level: "warning".into(),
362 code: 2086,
363 message: "Double quote to prevent globbing".into(),
364 },
365 &scripts[0].line_starts,
366 );
367 assert_eq!((finding.line, finding.column), (3, 3));
368 }
369
370 #[test]
371 fn keeps_command_attached_heredocs_as_shell_source() {
372 let source = "FROM alpine\nRUN <<EOF cat > /message\nhello\nEOF\n";
373 let instruction = &parse(source)[1];
374 let scripts = scripts_for_run(source, instruction, "sh");
375 assert_eq!(scripts.len(), 1);
376 assert_eq!(scripts[0].source, "<<EOF cat > /message\nhello\nEOF");
377 }
378
379 #[test]
380 fn recognizes_posix_shells_and_skips_non_posix_shells() {
381 let source = "SHELL [\"/bin/bash\", \"-c\"]\nSHELL [\"powershell\", \"-command\"]\n";
382 let instructions = parse(source);
383 assert_eq!(shell_dialect(&instructions[0]), Some("bash"));
384 assert_eq!(shell_dialect(&instructions[1]), None);
385 }
386}