dockerfile-roast 1.4.4

A Dockerfile linter with personality — catches bad practices with snarky, funny error messages
Documentation
use dockerfile_roast::parser::{parse_document, DiagnosticSeverity, InstructionForm, QuoteStyle};

fn diagnostic_codes(source: &str) -> Vec<&'static str> {
    parse_document(source)
        .diagnostics
        .iter()
        .map(|diagnostic| diagnostic.code)
        .collect()
}

#[test]
fn parser_directives_are_structured_and_control_the_escape_token() {
    let source = "# syntax=docker/dockerfile:1.18\n# escape=`\n# check=skip=JSONArgsRecommended\nFROM scratch\n";
    let document = parse_document(source);

    assert_eq!(document.escape, '`');
    assert_eq!(document.directives.len(), 3);
    assert_eq!(document.directives[0].name, "syntax");
    assert_eq!(document.directives[0].value, "docker/dockerfile:1.18");
    assert_eq!(
        document.directives[0].span.text(source),
        "# syntax=docker/dockerfile:1.18"
    );
    assert!(document.diagnostics.is_empty());
}

#[test]
fn spans_recover_exact_crlf_source_across_continuations() {
    let source = "FROM alpine:3.20\r\nRUN echo one \\\r\n    && echo two\r\n";
    let document = parse_document(source);
    let run = &document.instructions[1];

    assert_eq!(run.span.start.line, 2);
    assert_eq!(run.span.start.column, 1);
    assert_eq!(run.span.end.line, 3);
    assert_eq!(run.keyword_span.text(source), "RUN");
    assert_eq!(run.span.text(source), "RUN echo one \\\r\n    && echo two");
    assert_eq!(run.logical_arguments, "echo one     && echo two");
}

#[test]
fn arbitrary_buildkit_flags_are_structured_without_hiding_the_command() {
    let source = "# syntax=docker/dockerfile:1-labs\nFROM --platform=$BUILDPLATFORM alpine:3.20 AS build\nRUN --mount=type=cache,target=/root/.cache --network=none --security=insecure --device=vendor.com/device echo ok\nCOPY --link --parents --exclude=*.md --chmod=0755 --chown=1:1 /src /dst\n";
    let document = parse_document(source);

    let run = &document.instructions[1];
    assert_eq!(
        run.flags
            .iter()
            .map(|flag| flag.name.as_str())
            .collect::<Vec<_>>(),
        ["mount", "network", "security", "device"]
    );
    assert_eq!(run.command, "echo ok");
    assert_eq!(
        run.flags[0].value.as_deref(),
        Some("type=cache,target=/root/.cache")
    );

    let copy = &document.instructions[2];
    assert_eq!(
        copy.flags
            .iter()
            .map(|flag| flag.name.as_str())
            .collect::<Vec<_>>(),
        ["link", "parents", "exclude", "chmod", "chown"]
    );
    assert_eq!(copy.command, "/src /dst");
}

#[test]
fn heredocs_retain_content_metadata_and_exact_spans() {
    let source = "FROM alpine:3.20\nRUN 3<<'SCRIPT' cat && <<-DATA cat\necho $NOT_EXPANDED\nSCRIPT\n\tunicode: café 🔥\n\tDATA\nCMD [\"sh\"]\n";
    let document = parse_document(source);
    let run = &document.instructions[1];

    assert_eq!(run.heredocs.len(), 2);
    assert_eq!(run.heredocs[0].delimiter, "SCRIPT");
    assert_eq!(run.heredocs[0].file_descriptor, Some(3));
    assert!(!run.heredocs[0].expand);
    assert_eq!(run.heredocs[0].content, "echo $NOT_EXPANDED\n");
    assert_eq!(run.heredocs[0].marker_span.text(source), "3<<'SCRIPT'");
    assert_eq!(
        run.heredocs[0]
            .terminator_span
            .expect("terminator")
            .text(source),
        "SCRIPT"
    );

    assert_eq!(run.heredocs[1].delimiter, "DATA");
    assert!(run.heredocs[1].strip_tabs);
    assert!(run.heredocs[1].expand);
    assert_eq!(run.heredocs[1].content, "unicode: café 🔥\n");
    assert_eq!(run.span.end.line, 6);
    assert_eq!(document.instructions[2].line, 7);
}

#[test]
fn copy_and_onbuild_heredocs_consume_their_bodies() {
    let source = "FROM scratch\nCOPY <<EOF /message\nFROM is data\nEOF\nONBUILD RUN <<SCRIPT\necho later\nSCRIPT\nCMD [\"/message\"]\n";
    let document = parse_document(source);

    assert_eq!(
        document
            .instructions
            .iter()
            .map(|instruction| instruction.instruction.as_str())
            .collect::<Vec<_>>(),
        ["FROM", "COPY", "ONBUILD", "CMD"]
    );
    assert_eq!(
        document.instructions[1].heredocs[0].content,
        "FROM is data\n"
    );
    assert_eq!(document.instructions[2].heredocs[0].content, "echo later\n");
}

#[test]
fn json_and_shell_forms_are_distinguished_and_validated() {
    let source = "FROM scratch\nRUN [\"echo\", \"hello\"]\nCMD echo hello\nENTRYPOINT [\"unterminated\"\nSHELL powershell -command\n";
    let document = parse_document(source);

    assert_eq!(
        document.instructions[1].form,
        InstructionForm::Json(vec!["echo".into(), "hello".into()])
    );
    assert_eq!(document.instructions[2].form, InstructionForm::Shell);
    assert_eq!(document.instructions[3].form, InstructionForm::InvalidJson);
    assert_eq!(document.instructions[4].form, InstructionForm::Shell);
    assert_eq!(
        document
            .diagnostics
            .iter()
            .filter(|diagnostic| diagnostic.code == "P007")
            .count(),
        2
    );
}

#[test]
fn quotes_and_variable_expansions_are_structured() {
    let source = "FROM alpine:${VERSION:-3.20}\nENV A=$PLAIN B=\"${BRACED#prefix}\" C='$LITERAL' D=\\$ESCAPED\n";
    let document = parse_document(source);
    let env = &document.instructions[1];

    assert_eq!(
        document.instructions[0]
            .variables
            .iter()
            .map(|variable| (variable.name.as_str(), variable.modifier.as_deref()))
            .collect::<Vec<_>>(),
        [("VERSION", Some(":-3.20"))]
    );
    assert_eq!(
        env.variables
            .iter()
            .map(|variable| variable.name.as_str())
            .collect::<Vec<_>>(),
        ["PLAIN", "BRACED"]
    );
    assert_eq!(env.words[1].quote, QuoteStyle::Mixed);
    assert_eq!(env.words[1].value, "B=${BRACED#prefix}");
    assert_eq!(env.words[2].value, "C=$LITERAL");
    assert_eq!(env.words[3].value, "D=$ESCAPED");
}

#[test]
fn backtick_escape_keeps_windows_and_powershell_backslashes_literal() {
    let source = "# escape=`\nFROM mcr.microsoft.com/windows/nanoserver:ltsc2022\nSHELL [\"powershell\", \"-command\"]\nRUN Write-Host C:\\ProgramData\\app $env:TEMP\n";
    let document = parse_document(source);
    let run = &document.instructions[2];

    assert_eq!(run.words[1].value, "C:\\ProgramData\\app");
    assert_eq!(run.command, "Write-Host C:\\ProgramData\\app $env:TEMP");
    assert!(document.diagnostics.is_empty());
}

#[test]
fn unicode_words_and_heredoc_delimiters_are_lossless() {
    let source =
        "FROM alpine:3.20\nLABEL greeting=\"Բարեւ աշխարհ\"\nRUN <<'ՎԵՐՋ'\necho café 🔥\nՎԵՐՋ\n";
    let document = parse_document(source);

    assert_eq!(
        document.instructions[1].words[0].value,
        "greeting=Բարեւ աշխարհ"
    );
    assert_eq!(document.instructions[2].heredocs[0].delimiter, "ՎԵՐՋ");
    assert_eq!(
        document.instructions[2].heredocs[0].content,
        "echo café 🔥\n"
    );
}

#[test]
fn quoted_and_json_heredoc_markers_do_not_start_bodies() {
    let source = "FROM alpine:3.20\nRUN echo '<<NOT_A_HEREDOC'\nRUN [\"echo\", \"<<ALSO_NOT\"]\nCMD [\"sh\"]\n";
    let document = parse_document(source);

    assert!(document.instructions[1].heredocs.is_empty());
    assert!(document.instructions[2].heredocs.is_empty());
    assert_eq!(document.instructions.len(), 4);
}

#[test]
fn syntax_failures_are_recoverable_and_have_source_locations() {
    let source =
        "# escape=x\n# escape=\\\nFROM\nNOTREAL value\nCOPY [\"broken\"\nRUN <<EOF\nbody\n";
    let document = parse_document(source);
    let codes = document
        .diagnostics
        .iter()
        .map(|diagnostic| diagnostic.code)
        .collect::<Vec<_>>();

    assert!(codes.contains(&"P010"));
    assert!(codes.contains(&"P012"));
    assert!(codes.contains(&"P002"));
    assert!(codes.contains(&"P005"));
    assert!(codes.contains(&"P006"));
    assert!(codes.contains(&"P007"));
    assert!(document
        .diagnostics
        .iter()
        .filter(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
        .all(|diagnostic| diagnostic.span.start.line >= 1));
}

#[test]
fn empty_files_and_unterminated_continuations_report_diagnostics() {
    assert_eq!(diagnostic_codes(""), ["P001"]);
    assert!(diagnostic_codes("FROM alpine:3.20\\").contains(&"P003"));
}

#[test]
fn modern_fixture_parses_without_recovery_diagnostics() {
    let source = include_str!("fixtures/modern.Dockerfile");
    let document = parse_document(source);

    assert!(document.diagnostics.is_empty(), "{:?}", document.diagnostics);
    assert_eq!(document.instructions.len(), 6);
    assert_eq!(document.instructions[1].heredocs.len(), 1);
    assert_eq!(document.instructions[3].flags[0].name, "mount");
    assert_eq!(document.instructions[3].heredocs.len(), 1);
}