dockerfile_parser_rs/
ast.rs

1// https://docs.docker.com/reference/dockerfile/#overview
2
3use std::collections::BTreeMap;
4use std::fmt;
5
6use serde::Deserialize;
7use serde::Serialize;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10/// This enum represents available instructions in a Dockerfile and their associated data.
11pub enum Instruction {
12    /// Represents an ADD instruction in the Dockerfile.
13    ///
14    /// ### Example
15    ///
16    /// ```
17    /// use dockerfile_parser_rs::Instruction;
18    ///
19    /// let add = Instruction::Add {
20    ///     checksum: None,
21    ///     chown: None,
22    ///     chmod: None,
23    ///     link: None,
24    ///     sources: Vec::from([String::from("source1"), String::from("source2")]),
25    ///     destination: String::from("/destination"),
26    /// };
27    /// ```
28    Add {
29        checksum: Option<String>,
30        chown: Option<String>,
31        chmod: Option<String>,
32        link: Option<String>,
33        sources: Vec<String>,
34        destination: String,
35    },
36    /// Represents an ARG instruction in the Dockerfile.
37    ///
38    /// ### Example
39    ///
40    /// ```
41    /// use std::collections::BTreeMap;
42    ///
43    /// use dockerfile_parser_rs::Instruction;
44    ///
45    /// let arg = Instruction::Arg(BTreeMap::from([
46    ///     (String::from("ARG1"), Some(String::from("value1"))),
47    ///     (String::from("ARG2"), None),
48    /// ]));
49    /// ```
50    Arg(BTreeMap<String, Option<String>>),
51    /// Represents a CMD instruction in the Dockerfile.
52    ///
53    /// ### Example
54    ///
55    /// ```
56    /// use dockerfile_parser_rs::Instruction;
57    ///
58    /// let cmd = Instruction::Cmd(Vec::from([
59    ///     String::from("echo"),
60    ///     String::from("Hello, World!"),
61    /// ]));
62    /// ```
63    Cmd(Vec<String>),
64    /// Represents a comment in the Dockerfile.
65    ///
66    /// ### Example
67    ///
68    /// ```
69    /// use dockerfile_parser_rs::Instruction;
70    ///
71    /// let comment = Instruction::Comment(String::from("# This is a comment"));
72    /// ```
73    Comment(String),
74    /// Represents a COPY instruction in the Dockerfile.
75    ///
76    /// ### Example
77    ///
78    /// ```
79    /// use dockerfile_parser_rs::Instruction;
80    ///
81    /// let copy = Instruction::Copy {
82    ///     from: Some(String::from("builder")),
83    ///     chown: None,
84    ///     chmod: None,
85    ///     link: None,
86    ///     sources: Vec::from([String::from("source1"), String::from("source2")]),
87    ///     destination: String::from("/destination"),
88    /// };
89    /// ```
90    Copy {
91        from: Option<String>,
92        chown: Option<String>,
93        chmod: Option<String>,
94        link: Option<String>,
95        sources: Vec<String>,
96        destination: String,
97    },
98    /// Represents an empty line in the Dockerfile.
99    ///
100    /// ### Example
101    ///
102    /// ```
103    /// use dockerfile_parser_rs::Instruction;
104    ///
105    /// let empty = Instruction::Empty {};
106    /// ```
107    Empty {},
108    /// Represents an ENTRYPOINT instruction in the Dockerfile.
109    ///
110    /// ### Example
111    ///
112    /// ```
113    /// use dockerfile_parser_rs::Instruction;
114    ///
115    /// let entrypoint = Instruction::Entrypoint(Vec::from([String::from("entrypoint.sh")]));
116    /// ```
117    Entrypoint(Vec<String>),
118    /// Represents an ENV instruction in the Dockerfile.
119    ///
120    /// ### Example
121    ///
122    /// ```
123    /// use std::collections::BTreeMap;
124    ///
125    /// use dockerfile_parser_rs::Instruction;
126    ///
127    /// let env = Instruction::Env(BTreeMap::from([
128    ///     (String::from("ENV1"), String::from("value1")),
129    ///     (String::from("ENV2"), String::from("value2")),
130    /// ]));
131    /// ```
132    Env(BTreeMap<String, String>),
133    /// Represents an EXPOSE instruction in the Dockerfile.
134    ///
135    /// ### Example
136    ///
137    /// ```
138    /// use dockerfile_parser_rs::Instruction;
139    ///
140    /// let expose = Instruction::Expose {
141    ///     ports: Vec::from([String::from("8080")]),
142    /// };
143    /// ```
144    Expose { ports: Vec<String> },
145    /// Represents a FROM instruction in the Dockerfile.
146    ///
147    /// ### Example
148    ///
149    /// ```
150    /// use dockerfile_parser_rs::Instruction;
151    ///
152    /// let from = Instruction::From {
153    ///     platform: Some(String::from("linux/amd64")),
154    ///     image: String::from("docker.io/library/fedora:latest"),
155    ///     alias: Some(String::from("builder")),
156    /// };
157    /// ```
158    From {
159        platform: Option<String>,
160        image: String,
161        alias: Option<String>,
162    },
163    /// Represents a LABEL instruction in the Dockerfile.
164    ///
165    /// ### Example
166    ///
167    /// ```
168    /// use std::collections::BTreeMap;
169    ///
170    /// use dockerfile_parser_rs::Instruction;
171    ///
172    /// let label = Instruction::Label(BTreeMap::from([
173    ///     (String::from("version"), String::from("1.0")),
174    ///     (String::from("maintainer"), String::from("John Doe")),
175    /// ]));
176    /// ```
177    Label(BTreeMap<String, String>),
178    /// Represents a RUN instruction in the Dockerfile.
179    ///
180    /// ### Example
181    ///
182    /// ```
183    /// use dockerfile_parser_rs::Instruction;
184    ///
185    /// let run = Instruction::Run {
186    ///     mount: None,
187    ///     network: None,
188    ///     security: None,
189    ///     command: Vec::from([String::from("<<EOF")]),
190    ///     heredoc: Some(Vec::from([
191    ///         String::from("dnf upgrade -y"),
192    ///         String::from("dnf install -y rustup"),
193    ///         String::from("EOF"),
194    ///     ])),
195    /// };
196    /// ```
197    Run {
198        mount: Option<String>,
199        network: Option<String>,
200        security: Option<String>,
201        command: Vec<String>,
202        heredoc: Option<Vec<String>>,
203    },
204    /// Represents a SHELL instruction in the Dockerfile.
205    ///
206    /// ### Example
207    ///
208    /// ```
209    /// use dockerfile_parser_rs::Instruction;
210    ///
211    /// let shell = Instruction::Shell(Vec::from([String::from("/bin/sh"), String::from("-c")]));
212    /// ```
213    Shell(Vec<String>),
214    /// Represents a STOPSIGNAL instruction in the Dockerfile.
215    ///
216    /// ### Example
217    ///
218    /// ```
219    /// use dockerfile_parser_rs::Instruction;
220    ///
221    /// let stopsignal = Instruction::Stopsignal {
222    ///     signal: String::from("SIGTERM"),
223    /// };
224    /// ```
225    Stopsignal { signal: String },
226    /// Represents a USER instruction in the Dockerfile.
227    ///
228    /// ### Example
229    ///
230    /// ```
231    /// use dockerfile_parser_rs::Instruction;
232    ///
233    /// let user = Instruction::User {
234    ///     user: String::from("1001"),
235    ///     group: None,
236    /// };
237    /// ```
238    User { user: String, group: Option<String> },
239    /// Represents a VOLUME instruction in the Dockerfile.
240    ///
241    /// ### Example
242    ///
243    /// ```
244    /// use dockerfile_parser_rs::Instruction;
245    ///
246    /// let volume = Instruction::Volume {
247    ///     mounts: Vec::from([String::from("/data")]),
248    /// };
249    /// ```
250    Volume { mounts: Vec<String> },
251    /// Represents a WORKDIR instruction in the Dockerfile.
252    ///
253    /// ### Example
254    ///
255    /// ```
256    /// use dockerfile_parser_rs::Instruction;
257    ///
258    /// let workdir = Instruction::Workdir {
259    ///     path: String::from("/app"),
260    /// };
261    /// ```
262    Workdir { path: String },
263}
264
265impl fmt::Display for Instruction {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        match self {
268            Self::Add {
269                checksum,
270                chown,
271                chmod,
272                link,
273                sources,
274                destination,
275            } => {
276                let options = vec![
277                    helpers::format_instruction_option("checksum", checksum.as_ref()),
278                    helpers::format_instruction_option("chown", chown.as_ref()),
279                    helpers::format_instruction_option("chmod", chmod.as_ref()),
280                    helpers::format_instruction_option("link", link.as_ref()),
281                ];
282                let prefix = helpers::format_options_string(&options);
283                write!(f, "ADD {prefix}{} {destination}", sources.join(" "))
284            }
285            Self::Arg(args) => write!(f, "ARG {}", helpers::format_optional_btree_map(args)),
286            Self::Cmd(cmd) => write!(f, "CMD {cmd:?}"),
287            Self::Comment(comment) => write!(f, "{comment}"),
288            Self::Copy {
289                from,
290                chown,
291                chmod,
292                link,
293                sources,
294                destination,
295            } => {
296                let options = vec![
297                    helpers::format_instruction_option("from", from.as_ref()),
298                    helpers::format_instruction_option("chown", chown.as_ref()),
299                    helpers::format_instruction_option("chmod", chmod.as_ref()),
300                    helpers::format_instruction_option("link", link.as_ref()),
301                ];
302                let prefix = helpers::format_options_string(&options);
303                write!(f, "COPY {prefix}{} {destination}", sources.join(" "))
304            }
305            Self::Empty {} => write!(f, ""),
306            Self::Entrypoint(entrypoint) => write!(f, "ENTRYPOINT {entrypoint:?}"),
307            Self::Env(env) => write!(f, "ENV {}", helpers::format_btree_map(env)),
308            Self::Expose { ports } => write!(f, "EXPOSE {}", ports.join(" ")),
309            Self::From {
310                platform,
311                image,
312                alias,
313            } => {
314                let options = vec![helpers::format_instruction_option(
315                    "platform",
316                    platform.as_ref(),
317                )];
318                let prefix = helpers::format_options_string(&options);
319
320                let mut line = format!("FROM {prefix}{image}");
321                if let Some(alias) = alias {
322                    line.push_str(" AS ");
323                    line.push_str(alias);
324                }
325                write!(f, "{line}")
326            }
327            Self::Label(labels) => write!(f, "LABEL {}", helpers::format_btree_map(labels)),
328            Self::Run {
329                mount,
330                network,
331                security,
332                command,
333                heredoc,
334            } => {
335                let options = vec![
336                    helpers::format_instruction_option("mount", mount.as_ref()),
337                    helpers::format_instruction_option("network", network.as_ref()),
338                    helpers::format_instruction_option("security", security.as_ref()),
339                ];
340                let prefix = helpers::format_options_string(&options);
341                match heredoc {
342                    Some(heredoc) => write!(
343                        f,
344                        "RUN {prefix}{}\n{}",
345                        command.join(" "),
346                        heredoc.join("\n")
347                    ),
348                    None => write!(f, "RUN {prefix}{}", command.join(" ")),
349                }
350            }
351            Self::Shell(shell) => write!(f, "SHELL {shell:?}"),
352            Self::Stopsignal { signal } => write!(f, "STOPSIGNAL {signal}"),
353            Self::User { user, group } => match group {
354                Some(group) => write!(f, "USER {user}:{group}"),
355                None => write!(f, "USER {user}"),
356            },
357            Self::Volume { mounts } => write!(f, "VOLUME {mounts:?}"),
358            Self::Workdir { path } => write!(f, "WORKDIR {path}"),
359        }
360    }
361}
362
363mod helpers {
364    use std::collections::BTreeMap;
365
366    use crate::quoter::Quoter;
367
368    pub fn format_instruction_option(key: &str, value: Option<&String>) -> String {
369        value
370            .as_ref()
371            .map(|v| format!("--{key}={v}"))
372            .unwrap_or_default()
373    }
374
375    pub fn format_options_string(options: &[String]) -> String {
376        let result = options
377            .iter()
378            .filter(|s| !s.is_empty())
379            .cloned()
380            .collect::<Vec<String>>()
381            .join(" ");
382
383        if result.is_empty() {
384            String::new()
385        } else {
386            // add a space to separate options
387            format!("{result} ")
388        }
389    }
390
391    pub fn format_btree_map(pairs: &BTreeMap<String, String>) -> String {
392        pairs
393            .iter()
394            .map(|(key, value)| format!("{key}={}", value.enquote()))
395            .collect::<Vec<String>>()
396            .join(" ")
397    }
398
399    pub fn format_optional_btree_map(pairs: &BTreeMap<String, Option<String>>) -> String {
400        pairs
401            .iter()
402            .map(|(k, v)| v.as_ref().map_or_else(|| k.clone(), |v| format!("{k}={v}")))
403            .collect::<Vec<_>>()
404            .join(" ")
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn test_display_instruction_add() {
414        let instruction = Instruction::Add {
415            checksum: None,
416            chown: None,
417            chmod: None,
418            link: None,
419            sources: vec![String::from("source1"), String::from("source2")],
420            destination: String::from("/destination"),
421        };
422
423        let expected = "ADD source1 source2 /destination";
424        assert_eq!(instruction.to_string(), expected);
425    }
426
427    #[test]
428    fn test_display_instruction_arg() {
429        let instruction = Instruction::Arg(BTreeMap::from([
430            (String::from("ARG2"), None),
431            (String::from("ARG1"), Some(String::from("value1"))),
432        ]));
433
434        // must be sorted
435        let expected = "ARG ARG1=value1 ARG2";
436        assert_eq!(instruction.to_string(), expected);
437    }
438
439    #[test]
440    fn test_display_instruction_cmd() {
441        let instruction =
442            Instruction::Cmd(vec![String::from("echo"), String::from("Hello, World!")]);
443
444        let expected = "CMD [\"echo\", \"Hello, World!\"]";
445        assert_eq!(instruction.to_string(), expected);
446    }
447
448    #[test]
449    fn test_display_instruction_copy() {
450        let instruction = Instruction::Copy {
451            from: Some(String::from("builder")),
452            chown: None,
453            chmod: None,
454            link: None,
455            sources: vec![String::from("source1"), String::from("source2")],
456            destination: String::from("/destination"),
457        };
458
459        let expected = "COPY --from=builder source1 source2 /destination";
460        assert_eq!(instruction.to_string(), expected);
461    }
462
463    #[test]
464    fn test_display_instruction_entrypoint() {
465        let instruction = Instruction::Entrypoint(vec![String::from("entrypoint.sh")]);
466
467        let expected = "ENTRYPOINT [\"entrypoint.sh\"]";
468        assert_eq!(instruction.to_string(), expected);
469    }
470
471    #[test]
472    fn test_display_instruction_env() {
473        let instruction = Instruction::Env(BTreeMap::from([
474            (String::from("ENV2"), String::from("value2")),
475            (String::from("ENV1"), String::from("value1")),
476        ]));
477
478        // must be sorted
479        let expected = "ENV ENV1=\"value1\" ENV2=\"value2\"";
480        assert_eq!(instruction.to_string(), expected);
481    }
482
483    #[test]
484    fn test_display_instruction_expose() {
485        let instruction = Instruction::Expose {
486            ports: vec![String::from("80"), String::from("443")],
487        };
488
489        let expected = "EXPOSE 80 443";
490        assert_eq!(instruction.to_string(), expected);
491    }
492
493    #[test]
494    fn test_display_instruction_from() {
495        let instruction = Instruction::From {
496            platform: Some(String::from("linux/amd64")),
497            image: String::from("docker.io/library/fedora:latest"),
498            alias: Some(String::from("builder")),
499        };
500
501        let expected = "FROM --platform=linux/amd64 docker.io/library/fedora:latest AS builder";
502        assert_eq!(instruction.to_string(), expected);
503    }
504
505    #[test]
506    fn test_display_instruction_label() {
507        let instruction = Instruction::Label(BTreeMap::from([
508            (String::from("version"), String::from("1.0")),
509            (String::from("maintainer"), String::from("John Doe")),
510        ]));
511
512        // must be sorted
513        let expected = "LABEL maintainer=\"John Doe\" version=\"1.0\"";
514        assert_eq!(instruction.to_string(), expected);
515    }
516
517    #[test]
518    fn test_display_instruction_run() {
519        let instruction = Instruction::Run {
520            mount: None,
521            network: None,
522            security: None,
523            command: vec![String::from("cat"), String::from("/etc/os-release")],
524            heredoc: None,
525        };
526
527        let expected = "RUN cat /etc/os-release";
528        assert_eq!(instruction.to_string(), expected);
529    }
530
531    #[test]
532    fn test_display_instruction_run_with_heredoc() {
533        let instruction = Instruction::Run {
534            mount: None,
535            network: None,
536            security: None,
537            command: vec![String::from("<<EOF")],
538            heredoc: Some(vec![
539                String::from("dnf upgrade -y"),
540                String::from("dnf install -y rustup"),
541                String::from("EOF"),
542            ]),
543        };
544
545        let expected = "RUN <<EOF\ndnf upgrade -y\ndnf install -y rustup\nEOF";
546        assert_eq!(instruction.to_string(), expected);
547    }
548
549    #[test]
550    fn test_display_instruction_run_with_heredoc_and_tabs() {
551        let instruction = Instruction::Run {
552            mount: None,
553            network: None,
554            security: None,
555            command: vec![String::from("python"), String::from("<<EOF")],
556            heredoc: Some(vec![
557                String::from("def main():"),
558                String::from("\tx = 42"),
559                String::from("\tprint(x)"),
560                String::from(""),
561                String::from("main()"),
562                String::from("EOF"),
563            ]),
564        };
565
566        let expected = "RUN python <<EOF\ndef main():\n\tx = 42\n\tprint(x)\n\nmain()\nEOF";
567        assert_eq!(instruction.to_string(), expected);
568    }
569
570    #[test]
571    fn test_display_instruction_shell() {
572        let instruction = Instruction::Shell(vec![String::from("/bin/sh"), String::from("-c")]);
573
574        let expected = "SHELL [\"/bin/sh\", \"-c\"]";
575        assert_eq!(instruction.to_string(), expected);
576    }
577
578    #[test]
579    fn test_display_instruction_user() {
580        let instruction = Instruction::User {
581            user: String::from("root"),
582            group: Some(String::from("root")),
583        };
584
585        let expected = "USER root:root";
586        assert_eq!(instruction.to_string(), expected);
587    }
588
589    #[test]
590    fn test_display_instruction_volume() {
591        let instruction = Instruction::Volume {
592            mounts: vec![String::from("/data"), String::from("/var/log")],
593        };
594
595        let expected = "VOLUME [\"/data\", \"/var/log\"]";
596        assert_eq!(instruction.to_string(), expected);
597    }
598
599    #[test]
600    fn test_display_instruction_workdir() {
601        let instruction = Instruction::Workdir {
602            path: String::from("/app"),
603        };
604
605        let expected = "WORKDIR /app";
606        assert_eq!(instruction.to_string(), expected);
607    }
608
609    #[test]
610    fn test_display_instruction_comment() {
611        let instruction = Instruction::Comment(String::from("# This is a comment"));
612
613        let expected = "# This is a comment";
614        assert_eq!(instruction.to_string(), expected);
615    }
616
617    #[test]
618    fn test_display_instruction_empty() {
619        let instruction = Instruction::Empty {};
620
621        let expected = "";
622        assert_eq!(instruction.to_string(), expected);
623    }
624}