wdl-lint 0.27.0

Lint rules for Workflow Description Language (WDL) documents
Documentation
//! Ensures that the value for `container` keys in `runtime`/`requirements`
//! sections are well-formed.
//!
//! This check only occurs if the `container` key exists in the
//! `runtime`/`requirements` sections.

use wdl_analysis::Diagnostics;
use wdl_analysis::Example;
use wdl_analysis::LabeledSnippet;
use wdl_analysis::VisitReason;
use wdl_analysis::Visitor;
use wdl_ast::AstNode;
use wdl_ast::Diagnostic;
use wdl_ast::Span;
use wdl_ast::SyntaxKind;
use wdl_ast::SyntaxNode;
use wdl_ast::v1::RequirementsSection;
use wdl_ast::v1::RuntimeSection;
use wdl_ast::v1::common::container::Kind;
use wdl_ast::v1::common::container::value::Value;
use wdl_ast::v1::common::container::value::uri::ANY_CONTAINER_VALUE;

use crate::Rule;
use crate::Tag;
use crate::TagSet;

/// The identifier for the container value rule.
const ID: &str = "ContainerUri";

/// Ensures that values for `container` keys within `runtime`/`requirements`
/// sections are well-formed.
#[derive(Default, Debug, Clone, Copy)]
pub struct ContainerUriRule;

/// Creates a missing tag diagnostic.
fn missing_tag(span: Span) -> Diagnostic {
    Diagnostic::warning(String::from("container URI is missing a tag"))
        .with_rule(ID)
        .with_highlight(span)
        .with_fix(
            "add a tag to the container URI (e.g., `ubuntu@sha256:foobar` instead of `ubuntu`)",
        )
}

/// Creates a mutable tag diagnostic.
fn mutable_tag(span: Span) -> Diagnostic {
    Diagnostic::note(String::from("container URI uses a mutable tag"))
        .with_rule(ID)
        .with_highlight(span)
        .with_fix(
            "replace the mutable tag with its SHA256 equivalent (e.g., `ubuntu@sha256:foobar` \
             instead of `ubuntu:latest`)",
        )
}

/// Creates an "empty array" diagnostic.
fn empty_array(span: Span) -> Diagnostic {
    Diagnostic::warning(String::from(
        "empty arrays are ambiguous and should contain at least one entry",
    ))
    .with_rule(ID)
    .with_highlight(span)
    .with_fix("add an entry or remove the entry altogether")
}

/// Creates a diagnostic indicating that a single value array should instead be
/// a string literal.
fn array_to_string_literal(span: Span) -> Diagnostic {
    Diagnostic::note(String::from(
        "an array with a single value should be a string literal",
    ))
    .with_rule(ID)
    .with_highlight(span)
    .with_fix("change the array to a string literal representing the first value")
}

/// Creates a diagnostic indicating that an array contains one or more 'any'
/// URIs.
fn array_containing_anys(spans: impl Iterator<Item = Span>) -> Diagnostic {
    let mut diagnostic = Diagnostic::warning(format!(
        "container arrays containing `{ANY_CONTAINER_VALUE}` are ambiguous"
    ))
    .with_rule(ID)
    .with_fix(format!(
        "remove these entries or change the array to a string literal with the value of \
         `{ANY_CONTAINER_VALUE}`"
    ));

    for span in spans {
        diagnostic = diagnostic.with_highlight(span)
    }

    diagnostic
}

impl Rule for ContainerUriRule {
    fn id(&self) -> &'static str {
        ID
    }

    fn description(&self) -> &'static str {
        "Ensures that values for the `container` key within `runtime`/`requirements` sections are \
         well-formed."
    }

    fn explanation(&self) -> &'static str {
        "This rule checks the following:

- Containers should have a tag, as container URIs with no tags have no expectation that the \
         behavior of the containers won't change between runs.
- Further, immutable containers tagged with SHA256 sums are preferred. This is due to the \
         requirement from the WDL specification that tasks produce functionally equivalent output \
         across runs. When a mutable tag is used, there is a risk that changes to the container \
         will cause different behavior between runs.
- Use of the 'any' container URI (`*`) within an array of container URIs is ambiguous and should \
         be avoided.
- Empty container URI arrays are not disallowed by the specification but are ambiguous and should \
         be avoided.
- An array of container URIs with a single element should be changed to a single string value."
    }

    fn examples(&self) -> &'static [Example] {
        &[Example {
            negative: LabeledSnippet {
                label: None,
                snippet: r#"version 1.2

task say_hello {
    input {
        String name
    }

    command <<<
        echo "Hello, ~{name}!"
    >>>

    # No tag
    requirements {
        container: "ubuntu"
    }
}

task say_goodbye {
    input {
        String name
    }

    command <<<
        echo "Goodbye, ~{name}!"
    >>>

    # Unnecessary array
    requirements {
        container: [
            "ubuntu@sha256:cc925e589b7543b910fea57a240468940003fbfc0515245a495dd0ad8fe7cef1",
        ]
    }
}
"#,
            },
            revised: Some(LabeledSnippet {
                label: None,
                snippet: r#"version 1.2

task say_hello {
    input {
        String name
    }

    command <<<
        echo "Hello, ~{name}!"
    >>>

    requirements {
        container: "ubuntu@sha256:cc925e589b7543b910fea57a240468940003fbfc0515245a495dd0ad8fe7cef1"
    }
}

task say_goodbye {
    input {
        String name
    }

    command <<<
        echo "Goodbye, ~{name}!"
    >>>

    requirements {
        container: "ubuntu@sha256:cc925e589b7543b910fea57a240468940003fbfc0515245a495dd0ad8fe7cef1"
    }
}
"#,
            }),
        }]
    }

    fn tags(&self) -> TagSet {
        // NOTE: these are the justification for these tags:
        //
        // - Clarity because it resolves the ambiguous situations described in the
        //   explanation above.
        // - Portability because this resolves situations where different execution
        //   engines might behave differently (e.g., one container engine might always
        //   pull the latest image for a mutably tagged container whereas another may
        //   use a older, cached version until the user prompts it to upgrade).
        TagSet::new(&[Tag::Clarity, Tag::Portability])
    }

    fn exceptable_nodes(&self) -> Option<&'static [wdl_ast::SyntaxKind]> {
        Some(&[
            SyntaxKind::VersionStatementNode,
            SyntaxKind::RuntimeSectionNode,
            SyntaxKind::RequirementsSectionNode,
            SyntaxKind::RequirementsItemNode,
            SyntaxKind::RuntimeItemNode,
        ])
    }

    fn related_rules(&self) -> &'static [&'static str] {
        &[]
    }
}

impl Visitor for ContainerUriRule {
    fn reset(&mut self) {
        *self = Self;
    }

    fn runtime_section(
        &mut self,
        diagnostics: &mut Diagnostics,
        reason: VisitReason,
        section: &RuntimeSection,
    ) {
        if reason == VisitReason::Exit {
            return;
        }

        if let Some(container) = section.container()
            && let Ok(value) = container.value()
        {
            check_container_value(
                diagnostics,
                value,
                container.inner(),
                &self.exceptable_nodes(),
            );
        }
    }

    fn requirements_section(
        &mut self,
        diagnostics: &mut Diagnostics,
        reason: VisitReason,
        section: &RequirementsSection,
    ) {
        if reason == VisitReason::Exit {
            return;
        }

        if let Some(container) = section.container()
            && let Ok(value) = container.value()
        {
            check_container_value(
                diagnostics,
                value,
                container.inner(),
                &self.exceptable_nodes(),
            );
        }
    }
}

/// Examines the value of the `container` item in both the `runtime` and
/// `requirements` sections.
fn check_container_value(
    diagnostics: &mut Diagnostics,
    value: Value,
    node: &SyntaxNode,
    exceptable_nodes: &Option<&'static [SyntaxKind]>,
) {
    if let Kind::Array(array) = value.kind() {
        if array.is_empty() {
            diagnostics.exceptable_add(empty_array(value.expr().span()), node, exceptable_nodes);
        } else if array.len() == 1 {
            // SAFETY: we just checked to ensure that exactly one element exists in the
            // vec, so this will always unwrap.
            let uri = array.iter().next().unwrap();
            diagnostics.exceptable_add(
                array_to_string_literal(uri.literal_string().span()),
                node,
                exceptable_nodes,
            );
        } else {
            let mut anys = array.iter().filter(|uri| uri.kind().is_any()).peekable();

            if anys.peek().is_some() {
                diagnostics.exceptable_add(
                    array_containing_anys(anys.map(|any| any.literal_string().span())),
                    node,
                    exceptable_nodes,
                );
            }
        }
    }

    for uri in value.uris() {
        if let Some(entry) = uri.kind().as_entry() {
            if entry.tag().is_none() {
                diagnostics.exceptable_add(
                    missing_tag(uri.literal_string().span()),
                    node,
                    exceptable_nodes,
                );
            } else if !entry.immutable() {
                diagnostics.exceptable_add(
                    mutable_tag(uri.literal_string().span()),
                    node,
                    exceptable_nodes,
                );
            }
        }
    }
}