Skip to main content

dockerfile_roast/
shellcheck.rs

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