Skip to main content

dockerfile_roast/
shellcheck.rs

1//! Optional bridge to an installed ShellCheck executable.
2
3use 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
50/// Lint every shell-form RUN independently, matching Docker's one-shell-per-RUN
51/// execution model. `auto` skips cleanly when ShellCheck is not installed.
52pub 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        // PowerShell and cmd use incompatible syntax. Do not feed them to a
96        // POSIX analyzer and manufacture misleading diagnostics.
97        _ => 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    // `RUN <<EOF` is BuildKit's script-heredoc form. ShellCheck must receive
107    // the body itself, not the header, which would otherwise be an incomplete
108    // redirection with no command.
109    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}