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