Skip to main content

a3s_box_runtime/oci/build/dockerfile/
mod.rs

1//! Dockerfile parser.
2//!
3//! Parses a Dockerfile into a sequence of build instructions.
4//! Supports line continuations (`\`), comments, and both shell and JSON
5//! (exec) forms for CMD/ENTRYPOINT.
6
7use a3s_box_core::error::{BoxError, Result};
8
9mod parsers;
10mod tests;
11mod utils;
12
13/// A single Dockerfile instruction.
14#[derive(Debug, Clone, PartialEq)]
15pub enum Instruction {
16    /// `FROM <image> [AS <alias>]`
17    From {
18        image: String,
19        alias: Option<String>,
20    },
21    /// `RUN [--mount=type=cache,...] <command>` (shell form)
22    Run {
23        command: String,
24        cache_mounts: Vec<RunCacheMount>,
25    },
26    /// `COPY [--from=<stage>] [--chown=user[:group]] <src>... <dst>`
27    Copy {
28        src: Vec<String>,
29        dst: String,
30        from: Option<String>,
31        /// Owner to apply to copied files (`user[:group]`, numeric or named).
32        chown: Option<String>,
33    },
34    /// `WORKDIR <path>`
35    Workdir { path: String },
36    /// `ENV <key>=<value> [<key>=<value> ...]` or legacy `ENV <key> <value>`.
37    /// Carries one or more key/value pairs (Docker allows several per line).
38    Env { vars: Vec<(String, String)> },
39    /// `ENTRYPOINT ["exec", "form"]` or `ENTRYPOINT command`
40    Entrypoint { exec: Vec<String> },
41    /// `CMD ["exec", "form"]` or `CMD command`
42    Cmd { exec: Vec<String> },
43    /// `EXPOSE <port>[/<proto>] ...` — one or more ports on a single line.
44    Expose { ports: Vec<String> },
45    /// `LABEL <key>=<value> [<key>=<value> ...]` — one or more pairs per line.
46    Label { pairs: Vec<(String, String)> },
47    /// `USER <user>[:<group>]`
48    User { user: String },
49    /// `ARG <name>[=<default>]`
50    Arg {
51        name: String,
52        default: Option<String>,
53    },
54    /// `ADD <src>... <dst>`
55    Add {
56        src: Vec<String>,
57        dst: String,
58        chown: Option<String>,
59    },
60    /// `SHELL ["executable", "param1", ...]`
61    Shell { exec: Vec<String> },
62    /// `STOPSIGNAL <signal>`
63    StopSignal { signal: String },
64    /// `HEALTHCHECK [OPTIONS] CMD command` or `HEALTHCHECK NONE`
65    HealthCheck {
66        cmd: Option<Vec<String>>,
67        interval: Option<u64>,
68        timeout: Option<u64>,
69        retries: Option<u32>,
70        start_period: Option<u64>,
71    },
72    /// `ONBUILD <instruction>`
73    OnBuild { instruction: Box<Instruction> },
74    /// `VOLUME <path>...`
75    Volume { paths: Vec<String> },
76}
77
78/// Supported subset of Docker BuildKit `RUN --mount=...`.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct RunCacheMount {
81    pub raw: String,
82    pub target: String,
83}
84
85/// Parsed Dockerfile: a list of instructions in order.
86#[derive(Debug, Clone)]
87pub struct Dockerfile {
88    pub instructions: Vec<Instruction>,
89}
90
91impl Dockerfile {
92    /// Parse a Dockerfile from its text content.
93    pub fn parse(content: &str) -> Result<Self> {
94        let logical_lines = join_continuation_lines(content);
95        let mut instructions = Vec::new();
96
97        for (line_num, line) in logical_lines.iter().enumerate() {
98            let trimmed = line.trim();
99
100            // Skip empty lines and comments
101            if trimmed.is_empty() || trimmed.starts_with('#') {
102                continue;
103            }
104
105            let instruction = parse_instruction(trimmed, line_num + 1)?;
106            instructions.push(instruction);
107        }
108
109        if instructions.is_empty() {
110            return Err(BoxError::BuildError(
111                "Dockerfile is empty or contains no instructions".to_string(),
112            ));
113        }
114
115        // Validate: first non-ARG instruction must be FROM
116        let first_non_arg = instructions
117            .iter()
118            .find(|i| !matches!(i, Instruction::Arg { .. }));
119        if !matches!(first_non_arg, Some(Instruction::From { .. })) {
120            return Err(BoxError::BuildError(
121                "First instruction must be FROM (or ARG before FROM)".to_string(),
122            ));
123        }
124
125        Ok(Dockerfile { instructions })
126    }
127
128    /// Parse a Dockerfile from a file path.
129    pub fn from_file(path: &std::path::Path) -> Result<Self> {
130        let content = std::fs::read_to_string(path).map_err(|e| {
131            BoxError::BuildError(format!(
132                "Failed to read Dockerfile at {}: {}",
133                path.display(),
134                e
135            ))
136        })?;
137        Self::parse(&content)
138    }
139}
140
141/// Join lines ending with `\` into single logical lines.
142fn join_continuation_lines(content: &str) -> Vec<String> {
143    let mut logical_lines = Vec::new();
144    let mut current = String::new();
145
146    for line in content.lines() {
147        if let Some(stripped) = line.strip_suffix('\\') {
148            // Remove trailing backslash and append
149            current.push_str(stripped.trim_end());
150            current.push(' ');
151        } else {
152            current.push_str(line);
153            logical_lines.push(current.clone());
154            current.clear();
155        }
156    }
157
158    // Handle trailing continuation without final line
159    if !current.is_empty() {
160        logical_lines.push(current);
161    }
162
163    logical_lines
164}
165
166/// Parse a single logical line into an Instruction.
167pub(super) fn parse_instruction(line: &str, line_num: usize) -> Result<Instruction> {
168    // Split into keyword and rest
169    let (keyword, rest) = split_first_word(line);
170    let keyword_upper = keyword.to_uppercase();
171
172    match keyword_upper.as_str() {
173        "FROM" => parsers::parse_from(rest, line_num),
174        "RUN" => parsers::parse_run(rest, line_num),
175        "COPY" => parsers::parse_copy(rest, line_num),
176        "WORKDIR" => parsers::parse_workdir(rest, line_num),
177        "ENV" => parsers::parse_env(rest, line_num),
178        "ENTRYPOINT" => parsers::parse_entrypoint(rest, line_num),
179        "CMD" => parsers::parse_cmd(rest, line_num),
180        "EXPOSE" => parsers::parse_expose(rest, line_num),
181        "LABEL" => parsers::parse_label(rest, line_num),
182        "USER" => parsers::parse_user(rest, line_num),
183        "ARG" => parsers::parse_arg(rest, line_num),
184        "ADD" => parsers::parse_add(rest, line_num),
185        "SHELL" => parsers::parse_shell(rest, line_num),
186        "STOPSIGNAL" => parsers::parse_stopsignal(rest, line_num),
187        "HEALTHCHECK" => parsers::parse_healthcheck(rest, line_num),
188        "ONBUILD" => parsers::parse_onbuild(rest, line_num),
189        "VOLUME" => parsers::parse_volume(rest, line_num),
190        // MAINTAINER is deprecated but still valid in Docker: it builds and
191        // records the author. Accept it (don't fail the build), storing it as a
192        // `maintainer` label — the modern equivalent Docker itself recommends.
193        "MAINTAINER" => {
194            if rest.trim().is_empty() {
195                return Err(BoxError::BuildError(format!(
196                    "Line {}: MAINTAINER requires a value",
197                    line_num
198                )));
199            }
200            eprintln!(
201                "Warning: MAINTAINER is deprecated (line {}); recording as label maintainer=...",
202                line_num
203            );
204            Ok(Instruction::Label {
205                pairs: vec![("maintainer".to_string(), rest.trim().to_string())],
206            })
207        }
208        _ => Err(BoxError::BuildError(format!(
209            "Line {}: Unknown instruction '{}'",
210            line_num, keyword
211        ))),
212    }
213}
214
215/// Split a string into the first word and the rest.
216fn split_first_word(s: &str) -> (&str, &str) {
217    let s = s.trim();
218    match s.find(char::is_whitespace) {
219        Some(pos) => (&s[..pos], s[pos..].trim_start()),
220        None => (s, ""),
221    }
222}
223
224/// Parse a single instruction line (used by ONBUILD trigger execution).
225pub fn parse_single_instruction(line: &str) -> Result<Instruction> {
226    parse_instruction(line.trim(), 0)
227}