syncable_cli/analyzer/hadolint/rules/
dl3045.rs

1//! DL3045: COPY to a relative destination without WORKDIR set
2//!
3//! COPY to a relative path requires WORKDIR to be set to ensure
4//! predictable behavior.
5
6use 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        "DL3045",
15        Severity::Warning,
16        "`COPY` to a relative destination without `WORKDIR` set.",
17        |state, line, instr, _shell| {
18            match instr {
19                Instruction::From(base) => {
20                    // Track current stage
21                    let stage_name = base
22                        .alias
23                        .as_ref()
24                        .map(|a| a.as_str().to_string())
25                        .unwrap_or_else(|| base.image.name.clone());
26                    state.data.set_string("current_stage", &stage_name);
27
28                    // Check if parent stage had WORKDIR set
29                    let parent_had_workdir = state
30                        .data
31                        .set_contains("stages_with_workdir", &base.image.name);
32                    if parent_had_workdir {
33                        state.data.insert_to_set("stages_with_workdir", &stage_name);
34                    }
35                }
36                Instruction::Workdir(_) => {
37                    // Mark current stage as having WORKDIR set
38                    let stage = state
39                        .data
40                        .get_string("current_stage")
41                        .map(|s| s.to_string())
42                        .unwrap_or_else(|| "__none__".to_string());
43                    state.data.insert_to_set("stages_with_workdir", &stage);
44                }
45                Instruction::Copy(args, _) => {
46                    let dest = &args.dest;
47
48                    // Check if current stage has WORKDIR set
49                    let has_workdir = state
50                        .data
51                        .get_string("current_stage")
52                        .map(|s| state.data.set_contains("stages_with_workdir", s))
53                        .unwrap_or_else(|| {
54                            state.data.set_contains("stages_with_workdir", "__none__")
55                        });
56
57                    // Skip check if WORKDIR is set
58                    if has_workdir {
59                        return;
60                    }
61
62                    // Check if destination is absolute
63                    let trimmed = dest.trim_matches(|c| c == '"' || c == '\'');
64
65                    // Absolute paths are OK
66                    if trimmed.starts_with('/') {
67                        return;
68                    }
69
70                    // Windows absolute paths are OK
71                    if is_windows_absolute(trimmed) {
72                        return;
73                    }
74
75                    // Variable references are OK
76                    if trimmed.starts_with('$') {
77                        return;
78                    }
79
80                    // Relative path without WORKDIR
81                    state.add_failure(
82                        "DL3045",
83                        Severity::Warning,
84                        "`COPY` to a relative destination without `WORKDIR` set.",
85                        line,
86                    );
87                }
88                _ => {}
89            }
90        },
91    )
92}
93
94/// Check if path is a Windows absolute path.
95fn is_windows_absolute(path: &str) -> bool {
96    let chars: Vec<char> = path.chars().collect();
97    chars.len() >= 2 && chars[0].is_ascii_alphabetic() && chars[1] == ':'
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::analyzer::hadolint::parser::instruction::{BaseImage, CopyArgs, CopyFlags};
104    use crate::analyzer::hadolint::rules::Rule;
105
106    #[test]
107    fn test_absolute_dest() {
108        let rule = rule();
109        let mut state = RuleState::new();
110
111        let from = Instruction::From(BaseImage::new("ubuntu"));
112        let copy = Instruction::Copy(
113            CopyArgs::new(vec!["app.js".to_string()], "/app/"),
114            CopyFlags::default(),
115        );
116
117        rule.check(&mut state, 1, &from, None);
118        rule.check(&mut state, 2, &copy, None);
119        assert!(state.failures.is_empty());
120    }
121
122    #[test]
123    fn test_relative_dest_without_workdir() {
124        let rule = rule();
125        let mut state = RuleState::new();
126
127        let from = Instruction::From(BaseImage::new("ubuntu"));
128        let copy = Instruction::Copy(
129            CopyArgs::new(vec!["app.js".to_string()], "app/"),
130            CopyFlags::default(),
131        );
132
133        rule.check(&mut state, 1, &from, None);
134        rule.check(&mut state, 2, &copy, None);
135        assert_eq!(state.failures.len(), 1);
136        assert_eq!(state.failures[0].code.as_str(), "DL3045");
137    }
138
139    #[test]
140    fn test_relative_dest_with_workdir() {
141        let rule = rule();
142        let mut state = RuleState::new();
143
144        let from = Instruction::From(BaseImage::new("ubuntu"));
145        let workdir = Instruction::Workdir("/app".to_string());
146        let copy = Instruction::Copy(
147            CopyArgs::new(vec!["app.js".to_string()], "."),
148            CopyFlags::default(),
149        );
150
151        rule.check(&mut state, 1, &from, None);
152        rule.check(&mut state, 2, &workdir, None);
153        rule.check(&mut state, 3, &copy, None);
154        assert!(state.failures.is_empty());
155    }
156
157    #[test]
158    fn test_variable_dest() {
159        let rule = rule();
160        let mut state = RuleState::new();
161
162        let from = Instruction::From(BaseImage::new("ubuntu"));
163        let copy = Instruction::Copy(
164            CopyArgs::new(vec!["app.js".to_string()], "$APP_DIR"),
165            CopyFlags::default(),
166        );
167
168        rule.check(&mut state, 1, &from, None);
169        rule.check(&mut state, 2, &copy, None);
170        assert!(state.failures.is_empty());
171    }
172}