1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use a3s_acl::{
11 canonical_bytes_with_schema, canonical_digest_with_schema, parse_with_limits,
12 validate_document, AttributeSchema, Block, BlockSchema, CanonicalError, Cardinality, Document,
13 ParseLimits, Schema, SchemaDiagnosticCode, Value, ValueSchema,
14};
15use a3s_box_core::platform::Platform;
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19use super::engine::{BuildConfig, BuildNetworkPolicy};
20
21const BUILD_LABEL: &str = "oci";
22const MAX_PLAN_PATH_BYTES: usize = 255;
23const MAX_TARGET_BYTES: usize = 128;
24const BUILD_PLAN_LIMITS: ParseLimits = ParseLimits {
25 max_document_bytes: 16 * 1024,
26 max_nesting_depth: 2,
27 max_collection_items: 16,
28 max_token_bytes: 1024,
29 max_diagnostics: 16,
30};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "kebab-case")]
35pub enum BuildCachePolicy {
36 ContentAddressed,
38 Disabled,
40}
41
42impl BuildCachePolicy {
43 fn parse(value: &str) -> Result<Self, BoxBuildPlanError> {
44 match value {
45 "content-addressed" => Ok(Self::ContentAddressed),
46 "disabled" => Ok(Self::Disabled),
47 _ => Err(BoxBuildPlanError::invalid(
48 "cache",
49 "must be content-addressed or disabled",
50 )),
51 }
52 }
53
54 pub const fn as_str(self) -> &'static str {
56 match self {
57 Self::ContentAddressed => "content-addressed",
58 Self::Disabled => "disabled",
59 }
60 }
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
65pub struct BoxBuildOptions {
66 pub tag: Option<String>,
68 pub quiet: bool,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct BoxBuildPlan {
75 context: String,
76 file: String,
77 platform: Platform,
78 target: Option<String>,
79 network: BuildNetworkPolicy,
80 cache: BuildCachePolicy,
81}
82
83impl BoxBuildPlan {
84 pub const SCHEMA: &'static str = "a3s.box.build-plan.v1";
86
87 pub fn parse_acl(source: &str) -> Result<Self, BoxBuildPlanError> {
89 let document = parse_with_limits(source, BUILD_PLAN_LIMITS).map_err(|error| {
90 BoxBuildPlanError::AclParse {
91 message: error.message,
92 line: error.line,
93 column: error.column,
94 }
95 })?;
96 let schema = build_plan_schema();
97 let report = validate_document(&document, &schema);
98 if let Some(diagnostic) = report.diagnostics.into_iter().next() {
99 return Err(BoxBuildPlanError::Schema {
100 code: diagnostic.code,
101 path: diagnostic.path,
102 });
103 }
104
105 let block = document.blocks.first().ok_or_else(|| {
106 BoxBuildPlanError::invalid("build", "must contain exactly one build block")
107 })?;
108 if block.labels.first().map(String::as_str) != Some(BUILD_LABEL) {
109 return Err(BoxBuildPlanError::invalid(
110 "label",
111 "must be the exact value oci",
112 ));
113 }
114
115 let schema_value = required_string(block, "schema")?;
116 if schema_value != Self::SCHEMA {
117 return Err(BoxBuildPlanError::invalid(
118 "schema",
119 "is not a supported Box build-plan schema",
120 ));
121 }
122 let context = normalize_repository_path(required_string(block, "context")?, true)
123 .map_err(|reason| BoxBuildPlanError::invalid("context", reason))?;
124 let file = normalize_repository_path(required_string(block, "file")?, false)
125 .map_err(|reason| BoxBuildPlanError::invalid("file", reason))?;
126 let platform = parse_platform(required_string(block, "platform")?)?;
127 let network = BuildNetworkPolicy::parse_acl(required_string(block, "network")?)
128 .ok_or_else(|| BoxBuildPlanError::invalid("network", "must be none or outbound"))?;
129 let cache = BuildCachePolicy::parse(required_string(block, "cache")?)?;
130 let target = block
131 .attributes
132 .get("target")
133 .map(|value| {
134 value
135 .as_str()
136 .ok_or_else(|| BoxBuildPlanError::invalid("target", "must be a string"))
137 })
138 .transpose()?
139 .map(normalize_target)
140 .transpose()
141 .map_err(|reason| BoxBuildPlanError::invalid("target", reason))?;
142
143 Ok(Self {
144 context,
145 file,
146 platform,
147 target,
148 network,
149 cache,
150 })
151 }
152
153 pub fn canonical_acl(&self) -> Result<String, BoxBuildPlanError> {
155 let bytes = canonical_bytes_with_schema(&self.document(), &build_plan_schema())?;
156 String::from_utf8(bytes).map_err(|_| BoxBuildPlanError::CanonicalEncoding)
157 }
158
159 pub fn canonical_digest(&self) -> Result<String, BoxBuildPlanError> {
161 canonical_digest_with_schema(&self.document(), &build_plan_schema())
162 .map_err(BoxBuildPlanError::from)
163 }
164
165 pub fn compile(
169 &self,
170 source_root: &Path,
171 options: BoxBuildOptions,
172 ) -> Result<BuildConfig, BoxBuildPlanError> {
173 let source_root = canonical_source_root(source_root)?;
174 let context_dir = resolve_plan_path(&source_root, &self.context, "context", PathKind::Dir)?;
175 let dockerfile_path = resolve_plan_path(&source_root, &self.file, "file", PathKind::File)?;
176
177 Ok(BuildConfig {
178 context_dir,
179 dockerfile_path,
180 tag: options.tag,
181 build_args: HashMap::new(),
182 quiet: options.quiet,
183 platforms: vec![self.platform.clone()],
184 target: self.target.clone(),
185 no_cache: self.cache == BuildCachePolicy::Disabled,
186 network: self.network,
187 metrics: None,
188 run_pool: None,
189 })
190 }
191
192 pub fn context(&self) -> &str {
194 &self.context
195 }
196
197 pub fn file(&self) -> &str {
199 &self.file
200 }
201
202 pub fn platform(&self) -> &Platform {
204 &self.platform
205 }
206
207 pub fn target(&self) -> Option<&str> {
209 self.target.as_deref()
210 }
211
212 pub const fn network(&self) -> BuildNetworkPolicy {
214 self.network
215 }
216
217 pub const fn cache(&self) -> BuildCachePolicy {
219 self.cache
220 }
221
222 pub(in crate::oci::build) fn has_same_non_platform_intent(&self, other: &Self) -> bool {
225 self.context == other.context
226 && self.file == other.file
227 && self.target == other.target
228 && self.network == other.network
229 && self.cache == other.cache
230 }
231
232 fn document(&self) -> Document {
233 let mut attributes = HashMap::from([
234 (
235 "cache".to_string(),
236 Value::String(self.cache.as_str().to_string()),
237 ),
238 ("context".to_string(), Value::String(self.context.clone())),
239 ("file".to_string(), Value::String(self.file.clone())),
240 (
241 "network".to_string(),
242 Value::String(self.network.as_acl().to_string()),
243 ),
244 (
245 "platform".to_string(),
246 Value::String(self.platform.to_string()),
247 ),
248 (
249 "schema".to_string(),
250 Value::String(Self::SCHEMA.to_string()),
251 ),
252 ]);
253 if let Some(target) = &self.target {
254 attributes.insert("target".to_string(), Value::String(target.clone()));
255 }
256 Document {
257 blocks: vec![Block {
258 name: "build".to_string(),
259 labels: vec![BUILD_LABEL.to_string()],
260 blocks: Vec::new(),
261 attributes,
262 }],
263 }
264 }
265}
266
267#[derive(Debug, Error)]
269pub enum BoxBuildPlanError {
270 #[error("Box build plan ACL is invalid at {line}:{column}: {message}")]
272 AclParse {
273 message: String,
274 line: usize,
275 column: usize,
276 },
277 #[error("Box build plan schema rejected {path}: {code}")]
279 Schema {
280 code: SchemaDiagnosticCode,
281 path: String,
282 },
283 #[error("Box build plan field {field} {reason}")]
285 InvalidValue {
286 field: &'static str,
287 reason: &'static str,
288 },
289 #[error("Box build source root {reason}")]
291 InvalidSourceRoot { reason: &'static str },
292 #[error("Box build plan {field} path {reason}")]
294 UnsafePath {
295 field: &'static str,
296 reason: &'static str,
297 },
298 #[error("Box build plan canonicalization failed: {0}")]
300 Canonical(#[from] CanonicalError),
301 #[error("Box build plan canonical output was not UTF-8")]
303 CanonicalEncoding,
304}
305
306impl BoxBuildPlanError {
307 fn invalid(field: &'static str, reason: &'static str) -> Self {
308 Self::InvalidValue { field, reason }
309 }
310}
311
312#[derive(Clone, Copy)]
313enum PathKind {
314 Dir,
315 File,
316}
317
318fn build_plan_schema() -> Schema {
319 let body = Schema::new()
320 .attribute("schema", AttributeSchema::required(ValueSchema::string()))
321 .attribute("context", AttributeSchema::required(ValueSchema::string()))
322 .attribute("file", AttributeSchema::required(ValueSchema::string()))
323 .attribute("platform", AttributeSchema::required(ValueSchema::string()))
324 .attribute("target", AttributeSchema::optional(ValueSchema::string()))
325 .attribute("network", AttributeSchema::required(ValueSchema::string()))
326 .attribute("cache", AttributeSchema::required(ValueSchema::string()));
327 Schema::new().block(
328 "build",
329 BlockSchema::new(body)
330 .occurrences(Cardinality::exactly(1))
331 .labels(Cardinality::exactly(1)),
332 )
333}
334
335fn required_string<'a>(
336 block: &'a Block,
337 field: &'static str,
338) -> Result<&'a str, BoxBuildPlanError> {
339 block
340 .attributes
341 .get(field)
342 .and_then(Value::as_str)
343 .ok_or_else(|| BoxBuildPlanError::invalid(field, "must be a string"))
344}
345
346fn normalize_repository_path(value: &str, allow_root: bool) -> Result<String, &'static str> {
347 if value.is_empty()
348 || value.len() > MAX_PLAN_PATH_BYTES
349 || value.starts_with('/')
350 || value.contains(['\0', '\\', '%'])
351 {
352 return Err("must be a bounded relative POSIX path");
353 }
354 let value = value.strip_prefix("./").unwrap_or(value);
355 if value == "." {
356 return allow_root
357 .then(|| ".".to_string())
358 .ok_or("cannot be the repository root");
359 }
360 let segments = value.split('/').collect::<Vec<_>>();
361 if segments.iter().any(|segment| {
362 segment.is_empty()
363 || matches!(*segment, "." | "..")
364 || !segment.bytes().all(|byte| {
365 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'@' | b'+')
366 })
367 }) {
368 return Err("contains an unsafe path segment");
369 }
370 Ok(segments.join("/"))
371}
372
373fn normalize_target(value: &str) -> Result<String, &'static str> {
374 if value.is_empty()
375 || value.len() > MAX_TARGET_BYTES
376 || !value
377 .bytes()
378 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
379 {
380 return Err("must be a bounded Dockerfile stage name");
381 }
382 Ok(value.to_string())
383}
384
385fn parse_platform(value: &str) -> Result<Platform, BoxBuildPlanError> {
386 match value {
387 "linux/amd64" => Ok(Platform::linux_amd64()),
388 "linux/arm64" => Ok(Platform::linux_arm64()),
389 _ => Err(BoxBuildPlanError::invalid(
390 "platform",
391 "must be linux/amd64 or linux/arm64",
392 )),
393 }
394}
395
396fn canonical_source_root(source_root: &Path) -> Result<PathBuf, BoxBuildPlanError> {
397 if !source_root.is_absolute() {
398 return Err(BoxBuildPlanError::InvalidSourceRoot {
399 reason: "must be absolute",
400 });
401 }
402 let root = source_root
403 .canonicalize()
404 .map_err(|_| BoxBuildPlanError::InvalidSourceRoot {
405 reason: "does not exist or cannot be resolved",
406 })?;
407 if !root.is_dir() {
408 return Err(BoxBuildPlanError::InvalidSourceRoot {
409 reason: "must be a directory",
410 });
411 }
412 Ok(root)
413}
414
415fn resolve_plan_path(
416 source_root: &Path,
417 relative: &str,
418 field: &'static str,
419 kind: PathKind,
420) -> Result<PathBuf, BoxBuildPlanError> {
421 let unresolved = if relative == "." {
422 source_root.to_path_buf()
423 } else {
424 source_root.join(relative)
425 };
426 let resolved = unresolved
427 .canonicalize()
428 .map_err(|_| BoxBuildPlanError::UnsafePath {
429 field,
430 reason: "does not exist or cannot be resolved",
431 })?;
432 if !resolved.starts_with(source_root) {
433 return Err(BoxBuildPlanError::UnsafePath {
434 field,
435 reason: "escapes the admitted source root",
436 });
437 }
438 let expected_kind = match kind {
439 PathKind::Dir => resolved.is_dir(),
440 PathKind::File => resolved.is_file(),
441 };
442 if !expected_kind {
443 return Err(BoxBuildPlanError::UnsafePath {
444 field,
445 reason: match kind {
446 PathKind::Dir => "must resolve to a directory",
447 PathKind::File => "must resolve to a regular file",
448 },
449 });
450 }
451 Ok(resolved)
452}
453
454#[cfg(test)]
455mod tests;