syncable_cli/analyzer/hadolint/rules/
dl3022.rs

1//! DL3022: COPY --from should reference a previously defined FROM alias
2//!
3//! When using multi-stage builds, COPY --from should reference a stage
4//! that was previously defined.
5
6use crate::analyzer::hadolint::parser::instruction::Instruction;
7use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState};
8use crate::analyzer::hadolint::shell::ParsedShell;
9use crate::analyzer::hadolint::types::Severity;
10
11pub fn rule() -> CustomRule<impl Fn(&mut RuleState, u32, &Instruction, Option<&ParsedShell>) + Send + Sync> {
12    custom_rule(
13        "DL3022",
14        Severity::Warning,
15        "`COPY --from` should reference a previously defined `FROM` alias.",
16        |state, line, instr, _shell| {
17            match instr {
18                Instruction::From(base) => {
19                    // Track stage aliases
20                    if let Some(alias) = &base.alias {
21                        state.data.insert_to_set("stage_aliases", alias.as_str());
22                    }
23                    // Track stage index
24                    let stage_count = state.data.get_int("stage_count");
25                    state.data.set_int("stage_count", stage_count + 1);
26                }
27                Instruction::Copy(_, flags) => {
28                    if let Some(from) = &flags.from {
29                        // Check if it's a stage reference
30                        // It's valid if:
31                        // 1. It's a known alias
32                        // 2. It's a numeric index less than current stage count
33                        // 3. It's an external image reference
34
35                        let is_known_alias = state.data.set_contains("stage_aliases", from);
36                        let is_numeric_index = from.parse::<i64>().ok()
37                            .map(|n| n < state.data.get_int("stage_count"))
38                            .unwrap_or(false);
39
40                        // If it looks like an image name (contains / or :), allow it
41                        let is_external_image = from.contains('/') || from.contains(':');
42
43                        if !is_known_alias && !is_numeric_index && !is_external_image {
44                            state.add_failure(
45                                "DL3022",
46                                Severity::Warning,
47                                format!("`COPY --from={}` references an undefined stage.", from),
48                                line,
49                            );
50                        }
51                    }
52                }
53                _ => {}
54            }
55        },
56    )
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use crate::analyzer::hadolint::lint::{lint, LintResult};
63    use crate::analyzer::hadolint::config::HadolintConfig;
64
65    fn lint_dockerfile(content: &str) -> LintResult {
66        lint(content, &HadolintConfig::default())
67    }
68
69    #[test]
70    fn test_copy_from_valid_alias() {
71        let result = lint_dockerfile(
72            "FROM node:18 AS builder\nRUN npm ci\nFROM node:18-alpine\nCOPY --from=builder /app /app"
73        );
74        assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3022"));
75    }
76
77    #[test]
78    fn test_copy_from_invalid_alias() {
79        let result = lint_dockerfile(
80            "FROM node:18\nFROM node:18-alpine\nCOPY --from=nonexistent /app /app"
81        );
82        assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3022"));
83    }
84
85    #[test]
86    fn test_copy_from_numeric_index() {
87        let result = lint_dockerfile(
88            "FROM node:18\nRUN npm ci\nFROM node:18-alpine\nCOPY --from=0 /app /app"
89        );
90        assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3022"));
91    }
92
93    #[test]
94    fn test_copy_from_external_image() {
95        let result = lint_dockerfile(
96            "FROM node:18\nCOPY --from=nginx:latest /etc/nginx/nginx.conf /etc/nginx/"
97        );
98        assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3022"));
99    }
100
101    #[test]
102    fn test_copy_without_from() {
103        let result = lint_dockerfile("FROM node:18\nCOPY package.json /app/");
104        assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3022"));
105    }
106}