dockerfile_parser_rs/
ast.rs

1// https://docs.docker.com/reference/dockerfile/#overview
2
3use std::collections::BTreeMap;
4use std::fmt;
5
6use crate::quoter::Quoter;
7
8#[derive(Debug)]
9/// This enum represents available instructions in a Dockerfile.
10pub enum Instruction {
11    ADD {
12        checksum: Option<String>,
13        chown: Option<String>,
14        chmod: Option<String>,
15        link: Option<String>,
16        sources: Vec<String>,
17        destination: String,
18    },
19    ARG(BTreeMap<String, Option<String>>),
20    CMD(Vec<String>),
21    COPY {
22        from: Option<String>,
23        chown: Option<String>,
24        chmod: Option<String>,
25        link: Option<String>,
26        sources: Vec<String>,
27        destination: String,
28    },
29    ENTRYPOINT(Vec<String>),
30    ENV(BTreeMap<String, String>),
31    EXPOSE {
32        ports: Vec<String>,
33    },
34    FROM {
35        platform: Option<String>,
36        image: String,
37        alias: Option<String>,
38    },
39    LABEL(BTreeMap<String, String>),
40    /// # Example
41    ///
42    /// ```
43    /// let run = Instruction::RUN {
44    ///     mount: None,
45    ///     network: None,
46    ///     security: None,
47    ///     command: vec![String::from("<<EOF")],
48    ///     heredoc: Some(vec![
49    ///         String::from("dnf upgrade -y"),
50    ///         String::from("dnf install -y rustup"),
51    ///         String::from("EOF"),
52    ///     ]),
53    /// };
54    /// ```
55    RUN {
56        mount: Option<String>,
57        network: Option<String>,
58        security: Option<String>,
59        command: Vec<String>,
60        heredoc: Option<Vec<String>>,
61    },
62    SHELL(Vec<String>),
63    STOPSIGNAL {
64        signal: String,
65    },
66    USER {
67        user: String,
68        group: Option<String>,
69    },
70    VOLUME {
71        mounts: Vec<String>,
72    },
73    WORKDIR {
74        path: String,
75    },
76    //-------------//
77    //    extra    //
78    //-------------//
79    COMMENT(String),
80    EMPTY,
81}
82
83impl fmt::Display for Instruction {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            Instruction::ADD {
87                checksum,
88                chown,
89                chmod,
90                link,
91                sources,
92                destination,
93            } => {
94                let options = vec![
95                    helpers::format_instruction_option("checksum", checksum),
96                    helpers::format_instruction_option("chown", chown),
97                    helpers::format_instruction_option("chmod", chmod),
98                    helpers::format_instruction_option("link", link),
99                ];
100
101                let prefix = helpers::format_options_string(&options);
102                write!(f, "ADD {}{} {}", prefix, sources.join(" "), destination)
103            }
104            Instruction::ARG(args) => {
105                let arg_string = args
106                    .iter()
107                    .map(|(key, value)| {
108                        if let Some(default) = value {
109                            format!("{key}={default}")
110                        } else {
111                            key.to_owned()
112                        }
113                    })
114                    .collect::<Vec<String>>()
115                    .join(" ");
116
117                write!(f, "ARG {arg_string}")
118            }
119            Instruction::CMD(cmd) => write!(f, "CMD {cmd:?}"),
120            Instruction::COPY {
121                from,
122                chown,
123                chmod,
124                link,
125                sources,
126                destination,
127            } => {
128                let options = vec![
129                    helpers::format_instruction_option("from", from),
130                    helpers::format_instruction_option("chown", chown),
131                    helpers::format_instruction_option("chmod", chmod),
132                    helpers::format_instruction_option("link", link),
133                ];
134
135                let prefix = helpers::format_options_string(&options);
136                write!(f, "COPY {prefix}{} {destination}", sources.join(" "))
137            }
138            Instruction::ENTRYPOINT(entrypoint) => write!(f, "ENTRYPOINT {entrypoint:?}"),
139            Instruction::ENV(env) => {
140                write!(f, "ENV {}", helpers::format_btree_map(env))
141            }
142            Instruction::EXPOSE { ports } => write!(f, "EXPOSE {}", ports.join(" ")),
143            Instruction::FROM {
144                platform,
145                image,
146                alias,
147            } => {
148                let options = vec![helpers::format_instruction_option("platform", platform)];
149                let prefix = helpers::format_options_string(&options);
150
151                let mut line = format!("FROM {prefix}{image}");
152                if let Some(alias) = alias {
153                    line.push_str(" AS ");
154                    line.push_str(alias);
155                }
156
157                write!(f, "{line}")
158            }
159            Instruction::LABEL(labels) => {
160                write!(f, "LABEL {}", helpers::format_btree_map(labels))
161            }
162            Instruction::RUN {
163                mount,
164                network,
165                security,
166                command,
167                heredoc,
168            } => {
169                let options = vec![
170                    helpers::format_instruction_option("mount", mount),
171                    helpers::format_instruction_option("network", network),
172                    helpers::format_instruction_option("security", security),
173                ];
174
175                let prefix = helpers::format_options_string(&options);
176
177                if let Some(heredoc) = heredoc {
178                    write!(
179                        f,
180                        "RUN {prefix}{}\n{}",
181                        command.join(" "),
182                        heredoc.join("\n")
183                    )
184                } else {
185                    write!(f, "RUN {prefix}{command:?}")
186                }
187            }
188            Instruction::SHELL(shell) => write!(f, "SHELL {shell:?}"),
189            Instruction::STOPSIGNAL { signal } => write!(f, "STOPSIGNAL {signal}"),
190            Instruction::USER { user, group } => {
191                if let Some(group) = group {
192                    write!(f, "USER {user}:{group}")
193                } else {
194                    write!(f, "USER {user}")
195                }
196            }
197            Instruction::VOLUME { mounts } => write!(f, "VOLUME {mounts:?}"),
198            Instruction::WORKDIR { path } => write!(f, "WORKDIR {path}"),
199            //-------------//
200            //    extra    //
201            //-------------//
202            Instruction::COMMENT(comment) => write!(f, "{comment}"),
203            Instruction::EMPTY => write!(f, ""),
204        }
205    }
206}
207
208mod helpers {
209    use super::*;
210
211    pub fn format_instruction_option(key: &str, value: &Option<String>) -> String {
212        value
213            .as_ref()
214            .map(|v| format!("--{key}={v}"))
215            .unwrap_or_default()
216    }
217
218    pub fn format_options_string(options: &[String]) -> String {
219        let result = options
220            .iter()
221            .filter(|s| !s.is_empty())
222            .cloned()
223            .collect::<Vec<String>>()
224            .join(" ");
225
226        if result.is_empty() {
227            String::new()
228        } else {
229            // add a space to separate options
230            format!("{result} ")
231        }
232    }
233
234    pub fn format_btree_map(pairs: &BTreeMap<String, String>) -> String {
235        pairs
236            .iter()
237            .map(|(key, value)| format!("{key}={}", value.enquote()))
238            .collect::<Vec<String>>()
239            .join(" ")
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn test_display_instruction_add() {
249        let instruction = Instruction::ADD {
250            checksum: None,
251            chown: None,
252            chmod: None,
253            link: None,
254            sources: vec![String::from("source1"), String::from("source2")],
255            destination: String::from("/destination"),
256        };
257
258        let expected = "ADD source1 source2 /destination";
259        assert_eq!(instruction.to_string(), expected);
260    }
261
262    #[test]
263    fn test_display_instruction_arg() {
264        let instruction = Instruction::ARG(BTreeMap::from([
265            (String::from("ARG2"), None),
266            (String::from("ARG1"), Some(String::from("value1"))),
267        ]));
268
269        // must be sorted
270        let expected = "ARG ARG1=value1 ARG2";
271        assert_eq!(instruction.to_string(), expected);
272    }
273
274    #[test]
275    fn test_display_instruction_cmd() {
276        let instruction =
277            Instruction::CMD(vec![String::from("echo"), String::from("Hello, World!")]);
278
279        let expected = "CMD [\"echo\", \"Hello, World!\"]";
280        assert_eq!(instruction.to_string(), expected);
281    }
282
283    #[test]
284    fn test_display_instruction_copy() {
285        let instruction = Instruction::COPY {
286            from: Some(String::from("builder")),
287            chown: None,
288            chmod: None,
289            link: None,
290            sources: vec![String::from("source1"), String::from("source2")],
291            destination: String::from("/destination"),
292        };
293
294        let expected = "COPY --from=builder source1 source2 /destination";
295        assert_eq!(instruction.to_string(), expected);
296    }
297
298    #[test]
299    fn test_display_instruction_entrypoint() {
300        let instruction = Instruction::ENTRYPOINT(vec![String::from("entrypoint.sh")]);
301
302        let expected = "ENTRYPOINT [\"entrypoint.sh\"]";
303        assert_eq!(instruction.to_string(), expected);
304    }
305
306    #[test]
307    fn test_display_instruction_env() {
308        let instruction = Instruction::ENV(BTreeMap::from([
309            (String::from("ENV2"), String::from("value2")),
310            (String::from("ENV1"), String::from("value1")),
311        ]));
312
313        // must be sorted
314        let expected = "ENV ENV1=\"value1\" ENV2=\"value2\"";
315        assert_eq!(instruction.to_string(), expected);
316    }
317
318    #[test]
319    fn test_display_instruction_expose() {
320        let instruction = Instruction::EXPOSE {
321            ports: vec![String::from("80"), String::from("443")],
322        };
323
324        let expected = "EXPOSE 80 443";
325        assert_eq!(instruction.to_string(), expected);
326    }
327
328    #[test]
329    fn test_display_instruction_from() {
330        let instruction = Instruction::FROM {
331            platform: Some(String::from("linux/amd64")),
332            image: String::from("docker.io/library/fedora:latest"),
333            alias: Some(String::from("builder")),
334        };
335
336        let expected = "FROM --platform=linux/amd64 docker.io/library/fedora:latest AS builder";
337        assert_eq!(instruction.to_string(), expected);
338    }
339
340    #[test]
341    fn test_display_instruction_label() {
342        let instruction = Instruction::LABEL(BTreeMap::from([
343            (String::from("version"), String::from("1.0")),
344            (String::from("maintainer"), String::from("John Doe")),
345        ]));
346
347        // must be sorted
348        let expected = "LABEL maintainer=\"John Doe\" version=\"1.0\"";
349        assert_eq!(instruction.to_string(), expected);
350    }
351
352    #[test]
353    fn test_display_instruction_run() {
354        let instruction = Instruction::RUN {
355            mount: None,
356            network: None,
357            security: None,
358            command: vec![String::from("echo"), String::from("Hello, World!")],
359            heredoc: None,
360        };
361
362        let expected = "RUN [\"echo\", \"Hello, World!\"]";
363        assert_eq!(instruction.to_string(), expected);
364    }
365
366    #[test]
367    fn test_display_instruction_run_with_heredoc() {
368        let instruction = Instruction::RUN {
369            mount: None,
370            network: None,
371            security: None,
372            command: vec![String::from("<<EOF")],
373            heredoc: Some(vec![
374                String::from("dnf upgrade -y"),
375                String::from("dnf install -y rustup"),
376                String::from("EOF"),
377            ]),
378        };
379
380        let expected = "RUN <<EOF\ndnf upgrade -y\ndnf install -y rustup\nEOF";
381        assert_eq!(instruction.to_string(), expected);
382    }
383
384    #[test]
385    fn test_display_instruction_run_with_heredoc_and_tabs() {
386        let instruction = Instruction::RUN {
387            mount: None,
388            network: None,
389            security: None,
390            command: vec![String::from("python"), String::from("<<EOF")],
391            heredoc: Some(vec![
392                String::from("def main():"),
393                String::from("\tx = 42"),
394                String::from("\tprint(x)"),
395                String::from(""),
396                String::from("main()"),
397                String::from("EOF"),
398            ]),
399        };
400
401        let expected = "RUN python <<EOF\ndef main():\n\tx = 42\n\tprint(x)\n\nmain()\nEOF";
402        assert_eq!(instruction.to_string(), expected);
403    }
404
405    #[test]
406    fn test_display_instruction_shell() {
407        let instruction = Instruction::SHELL(vec![String::from("/bin/sh"), String::from("-c")]);
408
409        let expected = "SHELL [\"/bin/sh\", \"-c\"]";
410        assert_eq!(instruction.to_string(), expected);
411    }
412
413    #[test]
414    fn test_display_instruction_user() {
415        let instruction = Instruction::USER {
416            user: String::from("root"),
417            group: Some(String::from("root")),
418        };
419
420        let expected = "USER root:root";
421        assert_eq!(instruction.to_string(), expected);
422    }
423
424    #[test]
425    fn test_display_instruction_volume() {
426        let instruction = Instruction::VOLUME {
427            mounts: vec![String::from("/data"), String::from("/var/log")],
428        };
429
430        let expected = "VOLUME [\"/data\", \"/var/log\"]";
431        assert_eq!(instruction.to_string(), expected);
432    }
433
434    #[test]
435    fn test_display_instruction_workdir() {
436        let instruction = Instruction::WORKDIR {
437            path: String::from("/app"),
438        };
439
440        let expected = "WORKDIR /app";
441        assert_eq!(instruction.to_string(), expected);
442    }
443
444    #[test]
445    fn test_display_instruction_comment() {
446        let instruction = Instruction::COMMENT(String::from("# This is a comment"));
447
448        let expected = "# This is a comment";
449        assert_eq!(instruction.to_string(), expected);
450    }
451
452    #[test]
453    fn test_display_instruction_empty() {
454        let instruction = Instruction::EMPTY;
455
456        let expected = "";
457        assert_eq!(instruction.to_string(), expected);
458    }
459}