syncable_cli/analyzer/hadolint/rules/
dl3022.rs1use 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 if let Some(alias) = &base.alias {
21 state.data.insert_to_set("stage_aliases", alias.as_str());
22 }
23 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 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 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}