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: String,
24 cache_mounts: Vec<RunCacheMount>,
25 },
26 Copy {
28 src: Vec<String>,
29 dst: String,
30 from: Option<String>,
31 chown: Option<String>,
33 },
34 Workdir { path: String },
36 Env { vars: Vec<(String, String)> },
39 Entrypoint { exec: Vec<String> },
41 Cmd { exec: Vec<String> },
43 Expose { ports: Vec<String> },
45 Label { pairs: Vec<(String, String)> },
47 User { user: String },
49 Arg {
51 name: String,
52 default: Option<String>,
53 },
54 Add {
56 src: Vec<String>,
57 dst: String,
58 chown: Option<String>,
59 },
60 Shell { exec: Vec<String> },
62 StopSignal { signal: String },
64 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: Box<Instruction> },
74 Volume { paths: Vec<String> },
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct RunCacheMount {
81 pub raw: String,
82 pub target: String,
83}
84
85#[derive(Debug, Clone)]
87pub struct Dockerfile {
88 pub instructions: Vec<Instruction>,
89}
90
91impl Dockerfile {
92 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 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 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 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
141fn 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 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 if !current.is_empty() {
160 logical_lines.push(current);
161 }
162
163 logical_lines
164}
165
166pub(super) fn parse_instruction(line: &str, line_num: usize) -> Result<Instruction> {
168 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" => {
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
215fn 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
224pub fn parse_single_instruction(line: &str) -> Result<Instruction> {
226 parse_instruction(line.trim(), 0)
227}