Skip to main content

a3s_box_runtime/oci/build/
plan.rs

1//! Closed A3S ACL contract for OCI image builds owned by A3S Box.
2//!
3//! A build plan contains immutable product intent. Invocation-only values such
4//! as the destination tag and output verbosity stay outside the canonical ACL
5//! identity.
6
7use 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/// Cache behavior admitted by the Box build-plan contract.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "kebab-case")]
35pub enum BuildCachePolicy {
36    /// Reuse only content-addressed native build-engine cache entries.
37    ContentAddressed,
38    /// Rebuild every layer.
39    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    /// Stable ACL representation.
55    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/// Invocation-only options excluded from the canonical build-plan digest.
64#[derive(Debug, Clone, Default, PartialEq, Eq)]
65pub struct BoxBuildOptions {
66    /// Optional local image reference assigned by the native image store.
67    pub tag: Option<String>,
68    /// Suppress native build-engine progress output.
69    pub quiet: bool,
70}
71
72/// Immutable, closed Box-owned OCI build plan.
73#[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    /// Current closed ACL schema identity.
85    pub const SCHEMA: &'static str = "a3s.box.build-plan.v1";
86
87    /// Parse and validate one bounded A3S ACL build plan.
88    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    /// Canonical A3S ACL bytes represented as UTF-8 text with one final LF.
154    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    /// Lowercase SHA-256 identity over the canonical ACL bytes.
160    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    /// Resolve repository-relative paths and compile into Box's existing native
166    /// build engine. Both paths are canonicalized once, so later symlink swaps
167    /// cannot redirect this compiled invocation outside the admitted source.
168    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    /// Repository-relative build context.
193    pub fn context(&self) -> &str {
194        &self.context
195    }
196
197    /// Repository-relative Dockerfile or Containerfile.
198    pub fn file(&self) -> &str {
199        &self.file
200    }
201
202    /// Exact single target platform.
203    pub fn platform(&self) -> &Platform {
204        &self.platform
205    }
206
207    /// Optional multi-stage target.
208    pub fn target(&self) -> Option<&str> {
209        self.target.as_deref()
210    }
211
212    /// Network policy for Dockerfile execution instructions.
213    pub const fn network(&self) -> BuildNetworkPolicy {
214        self.network
215    }
216
217    /// Native build-cache policy.
218    pub const fn cache(&self) -> BuildCachePolicy {
219        self.cache
220    }
221
222    /// Whether two per-platform plans represent the same build intent once
223    /// their target platform is deliberately excluded.
224    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/// Stable failures from build-plan admission and source resolution.
268#[derive(Debug, Error)]
269pub enum BoxBuildPlanError {
270    /// The bounded ACL parser rejected the source.
271    #[error("Box build plan ACL is invalid at {line}:{column}: {message}")]
272    AclParse {
273        message: String,
274        line: usize,
275        column: usize,
276    },
277    /// The closed schema rejected an attribute, block, label count, or type.
278    #[error("Box build plan schema rejected {path}: {code}")]
279    Schema {
280        code: SchemaDiagnosticCode,
281        path: String,
282    },
283    /// A closed contract value was invalid.
284    #[error("Box build plan field {field} {reason}")]
285    InvalidValue {
286        field: &'static str,
287        reason: &'static str,
288    },
289    /// The caller did not provide a usable absolute source root.
290    #[error("Box build source root {reason}")]
291    InvalidSourceRoot { reason: &'static str },
292    /// A plan path was missing, the wrong kind, or escaped the source root.
293    #[error("Box build plan {field} path {reason}")]
294    UnsafePath {
295        field: &'static str,
296        reason: &'static str,
297    },
298    /// The validated AST could not be canonicalized.
299    #[error("Box build plan canonicalization failed: {0}")]
300    Canonical(#[from] CanonicalError),
301    /// ACL canonical bytes must always be UTF-8.
302    #[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;