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