a3s_box_runtime/oci/build/dockerfile/
mod.rs1use a3s_box_core::error::{BoxError, Result};
8
9mod parsers;
10mod tests;
11mod utils;
12
13#[derive(Debug, Clone, PartialEq)]
15pub enum Instruction {
16 From {
18 image: String,
19 alias: Option<String>,
20 },
21 Run {
23 command: RunCommand,
24 cache_mounts: Vec<RunCacheMount>,
25 bind_mounts: Vec<RunBindMount>,
26 tmpfs_mounts: Vec<RunTmpfsMount>,
27 },
28 Copy {
30 src: Vec<String>,
31 dst: String,
32 from: Option<String>,
33 chown: Option<String>,
35 },
36 Workdir { path: String },
38 Env { vars: Vec<(String, String)> },
41 Entrypoint { exec: Vec<String> },
43 Cmd { exec: Vec<String> },
45 Expose { ports: Vec<String> },
47 Label { pairs: Vec<(String, String)> },
49 User { user: String },
51 Arg {
53 name: String,
54 default: Option<String>,
55 },
56 Add {
58 src: Vec<String>,
59 dst: String,
60 chown: Option<String>,
61 },
62 Shell { exec: Vec<String> },
64 StopSignal { signal: String },
66 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: Box<Instruction> },
76 Volume { paths: Vec<String> },
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum RunCommand {
83 Shell(String),
85 Exec(Vec<String>),
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct RunCacheMount {
92 pub raw: String,
93 pub id: Option<String>,
94 pub from: Option<String>,
96 pub source: String,
98 pub sharing: RunCacheSharing,
99 pub mode: Option<u32>,
101 pub uid: Option<u32>,
103 pub gid: Option<u32>,
105 pub target: String,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct RunBindMount {
111 pub raw: String,
112 pub from: Option<String>,
114 pub source: String,
116 pub target: String,
118 pub read_write: bool,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct RunTmpfsMount {
126 pub raw: String,
127 pub target: String,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum RunCacheSharing {
134 Shared,
137 Locked,
139}
140
141#[derive(Debug, Clone)]
143pub struct Dockerfile {
144 pub instructions: Vec<Instruction>,
145}
146
147impl Dockerfile {
148 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 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 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 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
197fn 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 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 if !current.is_empty() {
216 logical_lines.push(current);
217 }
218
219 logical_lines
220}
221
222pub(super) fn parse_instruction(line: &str, line_num: usize) -> Result<Instruction> {
224 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" => {
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
271fn 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
280pub fn parse_single_instruction(line: &str) -> Result<Instruction> {
282 parse_instruction(line.trim(), 0)
283}