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 RUN/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|bind|tmpfs,...] <command>` (shell or exec form)
22    Run {
23        command: RunCommand,
24        cache_mounts: Vec<RunCacheMount>,
25        bind_mounts: Vec<RunBindMount>,
26        tmpfs_mounts: Vec<RunTmpfsMount>,
27    },
28    /// `COPY [--from=<stage>] [--chown=user[:group]] <src>... <dst>`
29    Copy {
30        src: Vec<String>,
31        dst: String,
32        from: Option<String>,
33        /// Owner to apply to copied files (`user[:group]`, numeric or named).
34        chown: Option<String>,
35    },
36    /// `WORKDIR <path>`
37    Workdir { path: String },
38    /// `ENV <key>=<value> [<key>=<value> ...]` or legacy `ENV <key> <value>`.
39    /// Carries one or more key/value pairs (Docker allows several per line).
40    Env { vars: Vec<(String, String)> },
41    /// `ENTRYPOINT ["exec", "form"]` or `ENTRYPOINT command`
42    Entrypoint { exec: Vec<String> },
43    /// `CMD ["exec", "form"]` or `CMD command`
44    Cmd { exec: Vec<String> },
45    /// `EXPOSE <port>[/<proto>] ...` — one or more ports on a single line.
46    Expose { ports: Vec<String> },
47    /// `LABEL <key>=<value> [<key>=<value> ...]` — one or more pairs per line.
48    Label { pairs: Vec<(String, String)> },
49    /// `USER <user>[:<group>]`
50    User { user: String },
51    /// `ARG <name>[=<default>]`
52    Arg {
53        name: String,
54        default: Option<String>,
55    },
56    /// `ADD <src>... <dst>`
57    Add {
58        src: Vec<String>,
59        dst: String,
60        chown: Option<String>,
61    },
62    /// `SHELL ["executable", "param1", ...]`
63    Shell { exec: Vec<String> },
64    /// `STOPSIGNAL <signal>`
65    StopSignal { signal: String },
66    /// `HEALTHCHECK [OPTIONS] CMD command` or `HEALTHCHECK NONE`
67    HealthCheck {
68        cmd: Option<Vec<String>>,
69        interval: Option<u64>,
70        timeout: Option<u64>,
71        retries: Option<u32>,
72        start_period: Option<u64>,
73    },
74    /// `ONBUILD <instruction>`
75    OnBuild { instruction: Box<Instruction> },
76    /// `VOLUME <path>...`
77    Volume { paths: Vec<String> },
78}
79
80/// Dockerfile RUN command form.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum RunCommand {
83    /// Shell form: `RUN echo hello`.
84    Shell(String),
85    /// Exec form: `RUN ["echo", "hello"]`.
86    Exec(Vec<String>),
87}
88
89/// Supported subset of Docker BuildKit `RUN --mount=...`.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct RunCacheMount {
92    pub raw: String,
93    pub id: Option<String>,
94    /// Optional source stage/image used to seed a new cache directory.
95    pub from: Option<String>,
96    /// Source path inside `from`; defaults to root.
97    pub source: String,
98    pub sharing: RunCacheSharing,
99    /// Optional mode for the cache mount root, parsed as octal.
100    pub mode: Option<u32>,
101    /// Optional owner uid for the cache mount root.
102    pub uid: Option<u32>,
103    /// Optional owner gid for the cache mount root.
104    pub gid: Option<u32>,
105    pub target: String,
106}
107
108/// Supported subset of Docker BuildKit `RUN --mount=type=bind`.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct RunBindMount {
111    pub raw: String,
112    /// Optional source stage/index. `None` means the build context.
113    pub from: Option<String>,
114    /// Source path inside the build context or source stage. Defaults to root.
115    pub source: String,
116    /// Target path inside the build rootfs; relative targets resolve from WORKDIR.
117    pub target: String,
118    /// `rw`/`readwrite` allows writes during RUN. Like BuildKit, writes are
119    /// discarded after the RUN and are not committed into the image layer.
120    pub read_write: bool,
121}
122
123/// Supported subset of Docker BuildKit `RUN --mount=type=tmpfs`.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct RunTmpfsMount {
126    pub raw: String,
127    /// Target path inside the build rootfs; relative targets resolve from WORKDIR.
128    pub target: String,
129}
130
131/// Supported cache sharing behavior for `RUN --mount=type=cache`.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum RunCacheSharing {
134    /// Docker/BuildKit default. The warm-pool overlay persists the shared cache
135    /// but serializes hydrate/publish for one key to avoid writeback races.
136    Shared,
137    /// Serialize writers for the same cache key.
138    Locked,
139}
140
141/// Parsed Dockerfile: a list of instructions in order.
142#[derive(Debug, Clone)]
143pub struct Dockerfile {
144    pub instructions: Vec<Instruction>,
145}
146
147impl Dockerfile {
148    /// Parse a Dockerfile from its text content.
149    pub fn parse(content: &str) -> Result<Self> {
150        let logical_lines = join_continuation_lines(content);
151        let mut instructions = Vec::new();
152
153        for (line_num, line) in logical_lines.iter().enumerate() {
154            let trimmed = line.trim();
155
156            // Skip empty lines and comments
157            if trimmed.is_empty() || trimmed.starts_with('#') {
158                continue;
159            }
160
161            let instruction = parse_instruction(trimmed, line_num + 1)?;
162            instructions.push(instruction);
163        }
164
165        if instructions.is_empty() {
166            return Err(BoxError::BuildError(
167                "Dockerfile is empty or contains no instructions".to_string(),
168            ));
169        }
170
171        // Validate: first non-ARG instruction must be FROM
172        let first_non_arg = instructions
173            .iter()
174            .find(|i| !matches!(i, Instruction::Arg { .. }));
175        if !matches!(first_non_arg, Some(Instruction::From { .. })) {
176            return Err(BoxError::BuildError(
177                "First instruction must be FROM (or ARG before FROM)".to_string(),
178            ));
179        }
180
181        Ok(Dockerfile { instructions })
182    }
183
184    /// Parse a Dockerfile from a file path.
185    pub fn from_file(path: &std::path::Path) -> Result<Self> {
186        let content = std::fs::read_to_string(path).map_err(|e| {
187            BoxError::BuildError(format!(
188                "Failed to read Dockerfile at {}: {}",
189                path.display(),
190                e
191            ))
192        })?;
193        Self::parse(&content)
194    }
195}
196
197/// Join lines ending with `\` into single logical lines.
198fn join_continuation_lines(content: &str) -> Vec<String> {
199    let mut logical_lines = Vec::new();
200    let mut current = String::new();
201
202    for line in content.lines() {
203        if let Some(stripped) = line.strip_suffix('\\') {
204            // Remove trailing backslash and append
205            current.push_str(stripped.trim_end());
206            current.push(' ');
207        } else {
208            current.push_str(line);
209            logical_lines.push(current.clone());
210            current.clear();
211        }
212    }
213
214    // Handle trailing continuation without final line
215    if !current.is_empty() {
216        logical_lines.push(current);
217    }
218
219    logical_lines
220}
221
222/// Parse a single logical line into an Instruction.
223pub(super) fn parse_instruction(line: &str, line_num: usize) -> Result<Instruction> {
224    // Split into keyword and rest
225    let (keyword, rest) = split_first_word(line);
226    let keyword_upper = keyword.to_uppercase();
227
228    match keyword_upper.as_str() {
229        "FROM" => parsers::parse_from(rest, line_num),
230        "RUN" => parsers::parse_run(rest, line_num),
231        "COPY" => parsers::parse_copy(rest, line_num),
232        "WORKDIR" => parsers::parse_workdir(rest, line_num),
233        "ENV" => parsers::parse_env(rest, line_num),
234        "ENTRYPOINT" => parsers::parse_entrypoint(rest, line_num),
235        "CMD" => parsers::parse_cmd(rest, line_num),
236        "EXPOSE" => parsers::parse_expose(rest, line_num),
237        "LABEL" => parsers::parse_label(rest, line_num),
238        "USER" => parsers::parse_user(rest, line_num),
239        "ARG" => parsers::parse_arg(rest, line_num),
240        "ADD" => parsers::parse_add(rest, line_num),
241        "SHELL" => parsers::parse_shell(rest, line_num),
242        "STOPSIGNAL" => parsers::parse_stopsignal(rest, line_num),
243        "HEALTHCHECK" => parsers::parse_healthcheck(rest, line_num),
244        "ONBUILD" => parsers::parse_onbuild(rest, line_num),
245        "VOLUME" => parsers::parse_volume(rest, line_num),
246        // MAINTAINER is deprecated but still valid in Docker: it builds and
247        // records the author. Accept it (don't fail the build), storing it as a
248        // `maintainer` label — the modern equivalent Docker itself recommends.
249        "MAINTAINER" => {
250            if rest.trim().is_empty() {
251                return Err(BoxError::BuildError(format!(
252                    "Line {}: MAINTAINER requires a value",
253                    line_num
254                )));
255            }
256            eprintln!(
257                "Warning: MAINTAINER is deprecated (line {}); recording as label maintainer=...",
258                line_num
259            );
260            Ok(Instruction::Label {
261                pairs: vec![("maintainer".to_string(), rest.trim().to_string())],
262            })
263        }
264        _ => Err(BoxError::BuildError(format!(
265            "Line {}: Unknown instruction '{}'",
266            line_num, keyword
267        ))),
268    }
269}
270
271/// Split a string into the first word and the rest.
272fn split_first_word(s: &str) -> (&str, &str) {
273    let s = s.trim();
274    match s.find(char::is_whitespace) {
275        Some(pos) => (&s[..pos], s[pos..].trim_start()),
276        None => (s, ""),
277    }
278}
279
280/// Parse a single instruction line (used by ONBUILD trigger execution).
281pub fn parse_single_instruction(line: &str) -> Result<Instruction> {
282    parse_instruction(line.trim(), 0)
283}