Skip to main content

a3s_box_runtime/oci/build/engine/
mod.rs

1//! Build engine for constructing OCI images from Dockerfiles.
2//!
3//! Orchestrates the build process: parses the Dockerfile, pulls the base image,
4//! executes each instruction, creates layers, and assembles the final OCI image.
5
6use std::collections::HashMap;
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use a3s_box_core::error::{BoxError, Result};
12use a3s_box_core::platform::Platform;
13
14use super::cache::{
15    hash_context_sources, BuildCache, BuildCacheExportIdentity, BuildCacheTrace, CachedLayer,
16    RecordedBuildCache,
17};
18use super::dockerfile::{Dockerfile, Instruction, RunBindMount, RunCacheMount};
19use super::dockerignore::DockerIgnore;
20use super::layer::{sha256_bytes, sha256_file, LayerInfo};
21use super::output::publish_single_build_output;
22pub use super::output::{BuildOutputDescriptor, BuildResult, OCI_IMAGE_MANIFEST_MEDIA_TYPE};
23use crate::oci::image::OciImageConfig;
24use crate::oci::layers::extract_layer;
25use crate::oci::store::ImageStore;
26use crate::oci::{ImagePuller, RegistryAuth};
27
28mod control;
29mod handlers;
30#[cfg(target_os = "linux")]
31mod run_process;
32mod stages;
33mod utils;
34
35#[cfg(test)]
36mod tests;
37
38use handlers::{
39    apply_base_config, execute_onbuild_trigger, handle_add, handle_copy, handle_run,
40    handle_run_with_pool, instruction_to_string,
41};
42use stages::{global_arg_decls, resolve_stage_rootfs, split_into_stages};
43use utils::{compute_diff_id, expand_args, format_size, resolve_path};
44
45// Build inputs carry no creation clock, so wall time must not contaminate OCI content identity.
46const REPRODUCIBLE_OCI_CREATED_AT: &str = "1970-01-01T00:00:00Z";
47
48pub(super) use control::{BuildExecutionControl, BuildExecutionObserver, BuildImageCommitPermit};
49
50pub(super) struct SupervisedBuildResult {
51    pub(super) output: BuildResult,
52    pub(super) cache: Option<RecordedBuildCache>,
53}
54
55/// Network access available to Dockerfile execution instructions.
56///
57/// Base-image and external-stage resolution remain trusted host-side OCI
58/// operations. `None` isolates every `RUN` network namespace and rejects
59/// remote-URL `ADD`.
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
61pub enum BuildNetworkPolicy {
62    /// Preserve the existing native engine's outbound network behavior.
63    #[default]
64    Outbound,
65    /// Deny network access from Dockerfile execution instructions.
66    None,
67}
68
69impl BuildNetworkPolicy {
70    /// Stable ACL representation.
71    pub const fn as_acl(self) -> &'static str {
72        match self {
73            Self::Outbound => "outbound",
74            Self::None => "none",
75        }
76    }
77
78    pub(crate) fn parse_acl(value: &str) -> Option<Self> {
79        match value {
80            "outbound" => Some(Self::Outbound),
81            "none" => Some(Self::None),
82            _ => None,
83        }
84    }
85}
86
87/// Configuration for a build operation.
88#[derive(Debug, Clone)]
89pub struct BuildConfig {
90    /// Path to the build context directory
91    pub context_dir: PathBuf,
92    /// Path to the Dockerfile (relative to context or absolute)
93    pub dockerfile_path: PathBuf,
94    /// Image tag (e.g., "myimage:latest")
95    pub tag: Option<String>,
96    /// Build arguments (ARG overrides)
97    pub build_args: HashMap<String, String>,
98    /// Suppress build output
99    pub quiet: bool,
100    /// Target platforms for multi-platform builds.
101    /// Empty means build for the host platform only.
102    pub platforms: Vec<Platform>,
103    /// Build only up to this stage (`--target`), by alias or numeric index.
104    /// `None` builds the final stage.
105    pub target: Option<String>,
106    /// Disable the layer build cache (`--no-cache`): every layer is rebuilt.
107    pub no_cache: bool,
108    /// Network policy for Dockerfile execution instructions.
109    pub network: BuildNetworkPolicy,
110    /// Prometheus metrics (optional).
111    pub metrics: Option<crate::prom::RuntimeMetrics>,
112    /// Execute Dockerfile RUN instructions through a warm-pool daemon lease.
113    pub run_pool: Option<BuildRunPoolConfig>,
114}
115
116/// Configuration for executing Dockerfile RUN instructions in a warm-pool VM.
117#[derive(Debug, Clone)]
118pub struct BuildRunPoolConfig {
119    /// Pool daemon Unix socket.
120    pub socket: String,
121    /// Helper VM image. `None` uses the daemon's default image.
122    pub image: Option<String>,
123    /// Helper VM vCPU count for lazily-created pools.
124    pub vcpus: u32,
125    /// Helper VM memory in MiB for lazily-created pools.
126    pub memory_mb: u32,
127    /// Guest path where the stage rootfs is mounted.
128    pub guest_rootfs: String,
129    /// RUN exec timeout in nanoseconds.
130    pub timeout_ns: u64,
131    /// Persistent cache directory for `RUN --mount=type=cache`.
132    pub run_cache_dir: PathBuf,
133}
134
135#[cfg_attr(not(feature = "pool"), allow(dead_code))]
136struct BuildRunPoolSession {
137    guest_rootfs: String,
138    timeout_ns: u64,
139    run_cache_dir: PathBuf,
140    #[cfg(feature = "pool")]
141    lease: crate::pool::PoolLeaseClient,
142}
143
144impl BuildRunPoolSession {
145    async fn acquire(config: &BuildRunPoolConfig, rootfs_dir: &Path) -> Result<Self> {
146        #[cfg(feature = "pool")]
147        {
148            let rootfs_dir = rootfs_dir.canonicalize().map_err(|e| {
149                BoxError::BuildError(format!(
150                    "Failed to canonicalize build RUN rootfs {}: {}",
151                    rootfs_dir.display(),
152                    e
153                ))
154            })?;
155            let volume = format!("{}:{}:rw", rootfs_dir.display(), config.guest_rootfs);
156            let lease = crate::pool::PoolLeaseClient::acquire(crate::pool::PoolClientLease {
157                socket: config.socket.clone(),
158                image: config.image.clone(),
159                volumes: vec![volume],
160                vcpus: config.vcpus,
161                memory_mb: config.memory_mb,
162            })
163            .await
164            .map_err(|e| {
165                BoxError::BuildError(format!(
166                    "Failed to lease warm-pool VM for Dockerfile RUN: {}",
167                    e
168                ))
169            })?;
170            Ok(Self {
171                guest_rootfs: config.guest_rootfs.clone(),
172                timeout_ns: config.timeout_ns,
173                run_cache_dir: config.run_cache_dir.clone(),
174                lease,
175            })
176        }
177
178        #[cfg(not(feature = "pool"))]
179        {
180            let _ = (config, rootfs_dir);
181            Err(BoxError::BuildError(
182                "Dockerfile RUN warm-pool execution requires the runtime 'pool' feature"
183                    .to_string(),
184            ))
185        }
186    }
187
188    async fn release(self) -> Result<()> {
189        #[cfg(feature = "pool")]
190        {
191            self.lease.release().await.map_err(|e| {
192                BoxError::BuildError(format!(
193                    "Failed to release warm-pool Dockerfile RUN lease: {}",
194                    e
195                ))
196            })
197        }
198
199        #[cfg(not(feature = "pool"))]
200        {
201            Ok(())
202        }
203    }
204}
205
206fn run_bind_mount_input_hash(
207    context_dir: &Path,
208    completed_stages: &[(Option<String>, PathBuf)],
209    bind_mounts: &[RunBindMount],
210) -> Option<String> {
211    let mut input = String::new();
212
213    for mount in bind_mounts {
214        if has_parent_component(&mount.source) {
215            return None;
216        }
217
218        let source = if mount.source.is_empty() {
219            "."
220        } else {
221            mount.source.as_str()
222        };
223        let (origin, source_root) = match mount.from.as_deref() {
224            Some(from_ref) => (
225                format!("stage:{from_ref}"),
226                resolve_stage_rootfs(from_ref, completed_stages).ok()?,
227            ),
228            None => ("context".to_string(), context_dir),
229        };
230
231        let source_hash = hash_context_sources(source_root, &[source.to_string()])?;
232        input.push_str(&origin);
233        input.push('\0');
234        input.push_str(source);
235        input.push('\0');
236        input.push_str(&source_hash);
237        input.push('\0');
238
239        if mount.from.is_none() {
240            let dockerignore = context_dir.join(".dockerignore");
241            if let Ok(bytes) = std::fs::read(&dockerignore) {
242                input.push_str(".dockerignore");
243                input.push('\0');
244                input.push_str(&sha256_bytes(&bytes));
245                input.push('\0');
246            }
247        }
248    }
249
250    Some(sha256_bytes(input.as_bytes()))
251}
252
253fn run_cache_mount_input_hash(
254    completed_stages: &[(Option<String>, PathBuf)],
255    cache_mounts: &[RunCacheMount],
256) -> Option<String> {
257    let mut input = String::new();
258    let mut saw_seeded_cache = false;
259
260    for mount in cache_mounts {
261        let Some(from_ref) = mount.from.as_deref() else {
262            continue;
263        };
264        if has_parent_component(&mount.source) {
265            return None;
266        }
267
268        saw_seeded_cache = true;
269        let source = if mount.source.is_empty() {
270            "."
271        } else {
272            mount.source.as_str()
273        };
274        let source_root = resolve_stage_rootfs(from_ref, completed_stages).ok()?;
275        let source_hash = hash_context_sources(source_root, &[source.to_string()])?;
276        input.push_str("cache-seed:");
277        input.push_str(from_ref);
278        input.push('\0');
279        input.push_str(source);
280        input.push('\0');
281        input.push_str(&source_hash);
282        input.push('\0');
283    }
284
285    saw_seeded_cache.then(|| sha256_bytes(input.as_bytes()))
286}
287
288fn run_mount_input_hash(
289    context_dir: &Path,
290    completed_stages: &[(Option<String>, PathBuf)],
291    cache_mounts: &[RunCacheMount],
292    bind_mounts: &[RunBindMount],
293) -> Option<String> {
294    let bind_hash = if bind_mounts.is_empty() {
295        None
296    } else {
297        run_bind_mount_input_hash(context_dir, completed_stages, bind_mounts)
298    };
299    let cache_hash = run_cache_mount_input_hash(completed_stages, cache_mounts);
300
301    match (bind_hash, cache_hash) {
302        (None, None) => None,
303        (Some(hash), None) | (None, Some(hash)) => Some(hash),
304        (Some(bind_hash), Some(cache_hash)) => Some(sha256_bytes(
305            format!("bind\0{bind_hash}\0cache\0{cache_hash}").as_bytes(),
306        )),
307    }
308}
309
310fn has_parent_component(path: &str) -> bool {
311    Path::new(path)
312        .components()
313        .any(|component| matches!(component, std::path::Component::ParentDir))
314}
315
316async fn resolve_run_mount_source_roots(
317    completed_stages: &[(Option<String>, PathBuf)],
318    bind_mounts: &[RunBindMount],
319    cache_mounts: &[RunCacheMount],
320    store: &Arc<ImageStore>,
321    build_dir: &Path,
322    external_from_rootfs: &mut HashMap<String, PathBuf>,
323) -> Result<Option<Vec<(Option<String>, PathBuf)>>> {
324    let mut roots: Option<Vec<(Option<String>, PathBuf)>> = None;
325    let mut external_refs = HashSet::new();
326
327    let mut from_refs: Vec<&str> = Vec::new();
328    from_refs.extend(bind_mounts.iter().filter_map(|mount| mount.from.as_deref()));
329    from_refs.extend(
330        cache_mounts
331            .iter()
332            .filter_map(|mount| mount.from.as_deref()),
333    );
334
335    for from_ref in from_refs {
336        if resolve_stage_rootfs(from_ref, completed_stages).is_ok()
337            || roots
338                .as_deref()
339                .is_some_and(|resolved| resolve_stage_rootfs(from_ref, resolved).is_ok())
340        {
341            continue;
342        }
343
344        if !external_refs.insert(from_ref.to_string()) {
345            continue;
346        }
347
348        let rootfs = resolve_external_from_rootfs(
349            from_ref,
350            "RUN bind mount",
351            store,
352            build_dir,
353            external_from_rootfs,
354        )
355        .await?;
356        roots
357            .get_or_insert_with(|| completed_stages.to_vec())
358            .push((Some(from_ref.to_string()), rootfs));
359    }
360
361    Ok(roots)
362}
363
364/// Mutable state accumulated during the build.
365pub(super) struct BuildState {
366    /// Working directory inside the image
367    pub(super) workdir: String,
368    /// Environment variables
369    pub(super) env: Vec<(String, String)>,
370    /// Entrypoint
371    pub(super) entrypoint: Option<Vec<String>>,
372    /// Default command
373    pub(super) cmd: Option<Vec<String>>,
374    /// User
375    pub(super) user: Option<String>,
376    /// Exposed ports
377    pub(super) exposed_ports: Vec<String>,
378    /// Labels
379    pub(super) labels: HashMap<String, String>,
380    /// Layer info accumulated during build
381    pub(super) layers: Vec<LayerInfo>,
382    /// Diff IDs (uncompressed layer digests) for the OCI config
383    pub(super) diff_ids: Vec<String>,
384    /// History entries
385    pub(super) history: Vec<HistoryEntry>,
386    /// Build arguments (all `--build-arg` values plus ARG defaults). A value is
387    /// only usable in variable expansion if its name is also in `declared_args`.
388    pub(super) build_args: HashMap<String, String>,
389    /// Names declared via an `ARG` instruction in scope for this stage (plus any
390    /// global pre-FROM ARGs). Docker only substitutes `$NAME` for declared names;
391    /// an undeclared `--build-arg` is ignored for expansion.
392    pub(super) declared_args: HashSet<String>,
393    /// Shell override (default: ["/bin/sh", "-c"])
394    pub(super) shell: Vec<String>,
395    /// Stop signal
396    pub(super) stop_signal: Option<String>,
397    /// Health check configuration
398    pub(super) health_check: Option<OciHealthCheck>,
399    /// ONBUILD triggers to store in the image config
400    pub(super) onbuild: Vec<String>,
401    /// Volumes declared via VOLUME instruction
402    pub(super) volumes: Vec<String>,
403}
404
405/// A single history entry for the OCI config.
406#[derive(Debug, Clone)]
407pub(super) struct HistoryEntry {
408    pub(super) created_by: String,
409    pub(super) empty_layer: bool,
410}
411
412pub use crate::oci::image::OciHealthCheck;
413
414impl BuildState {
415    fn new(build_args: HashMap<String, String>) -> Self {
416        Self {
417            workdir: "/".to_string(),
418            env: Vec::new(),
419            entrypoint: None,
420            cmd: None,
421            user: None,
422            exposed_ports: Vec::new(),
423            labels: HashMap::new(),
424            layers: Vec::new(),
425            diff_ids: Vec::new(),
426            history: Vec::new(),
427            build_args,
428            declared_args: HashSet::new(),
429            shell: vec!["/bin/sh".to_string(), "-c".to_string()],
430            stop_signal: None,
431            health_check: None,
432            onbuild: Vec::new(),
433            volumes: Vec::new(),
434        }
435    }
436
437    /// Build args whose names were declared via `ARG` (gates `$NAME` expansion,
438    /// so an undeclared `--build-arg` is not substituted — matching Docker).
439    fn declared_build_args(&self) -> HashMap<String, String> {
440        self.build_args
441            .iter()
442            .filter(|(name, _)| self.declared_args.contains(*name))
443            .map(|(name, value)| (name.clone(), value.clone()))
444            .collect()
445    }
446
447    /// Variables in scope for `$NAME`/`${NAME}` expansion in ENV/WORKDIR/FROM:
448    /// declared ARG values overlaid with already-set ENV (ENV wins), matching
449    /// Docker. An undeclared/unset name is left untouched by `expand_args`.
450    fn expansion_vars(&self) -> HashMap<String, String> {
451        let mut vars = self.declared_build_args();
452        for (key, value) in &self.env {
453            vars.insert(key.clone(), value.clone());
454        }
455        vars
456    }
457
458    /// Environment for RUN: declared ARG values are available while executing
459    /// the command, and ENV values override ARGs with the same name. ARGs are
460    /// not persisted into the final image config unless an ENV stores them.
461    fn run_env(&self) -> Vec<(String, String)> {
462        let mut vars = self.declared_build_args();
463        for (key, value) in &self.env {
464            vars.insert(key.clone(), value.clone());
465        }
466        let mut pairs = vars.into_iter().collect::<Vec<_>>();
467        pairs.sort_by(|a, b| a.0.cmp(&b.0));
468        pairs
469    }
470
471    /// Seed a global (pre-FROM) ARG into this stage: declare its name and apply
472    /// its default unless a `--build-arg` already overrides it.
473    fn seed_global_arg(&mut self, name: &str, default: Option<&str>) {
474        self.declared_args.insert(name.to_string());
475        if !self.build_args.contains_key(name) {
476            if let Some(val) = default {
477                self.build_args.insert(name.to_string(), val.to_string());
478            }
479        }
480    }
481}
482
483/// Execute a full image build from a Dockerfile.
484///
485/// # Process
486///
487/// 1. Parse the Dockerfile
488/// 2. Pull the base image (FROM)
489/// 3. Extract base image layers into a temporary rootfs
490/// 4. Execute each instruction, creating layers as needed
491/// 5. Assemble the final OCI image layout
492/// 6. Store in the image store with the given tag
493///
494/// Supports multi-stage builds: each FROM starts a new stage. Only the final
495/// stage produces the output image. `COPY --from=<stage>` copies from a
496/// previous stage's rootfs.
497pub async fn build(config: BuildConfig, store: Arc<ImageStore>) -> Result<BuildResult> {
498    validate_build_config(&config)?;
499
500    let build_dir = tempfile::TempDir::new()
501        .map_err(|e| BoxError::BuildError(format!("Failed to create build directory: {}", e)))?;
502    build_in_workspace(config, store, build_dir.path(), None, None)
503        .await
504        .map(|result| result.output)
505}
506
507/// Execute the same native engine in one journal-owned workspace.
508pub(super) async fn build_supervised(
509    config: BuildConfig,
510    store: Arc<ImageStore>,
511    workspace: &Path,
512    control: BuildExecutionControl,
513    cache_identity: Option<BuildCacheExportIdentity>,
514) -> Result<SupervisedBuildResult> {
515    validate_build_config(&config)?;
516    control.ensure_active().await?;
517    build_in_workspace(config, store, workspace, Some(control), cache_identity).await
518}
519
520async fn build_in_workspace(
521    config: BuildConfig,
522    store: Arc<ImageStore>,
523    build_dir: &Path,
524    control: Option<BuildExecutionControl>,
525    cache_identity: Option<BuildCacheExportIdentity>,
526) -> Result<SupervisedBuildResult> {
527    // Parse Dockerfile
528    let dockerfile = Dockerfile::from_file(&config.dockerfile_path)?;
529
530    // Load the context's .dockerignore once; applied to every context COPY/ADD.
531    let dockerignore = DockerIgnore::load(&config.context_dir);
532
533    if !config.quiet {
534        println!("Building from {}", config.dockerfile_path.display());
535        if !dockerignore.is_empty() {
536            println!("Using .dockerignore");
537        }
538    }
539
540    // Split instructions into stages by FROM
541    let stages = split_into_stages(&dockerfile.instructions);
542    // Global (pre-FROM) ARG declarations: in scope for every stage. Stage 0 also
543    // processes them inline (they are prepended to it), so they are only seeded
544    // into later stages to avoid double-counting.
545    let global_args = global_arg_decls(&dockerfile.instructions);
546    let total_stages = stages.len();
547
548    // Resolve --target to the stage that produces the output image (by alias or
549    // numeric index). Without --target the final stage is the output. Stages
550    // after the target are never executed.
551    let output_stage_idx = match config.target.as_deref() {
552        Some(target) => stages
553            .iter()
554            .position(|s| s.alias.as_deref() == Some(target))
555            .or_else(|| target.parse::<usize>().ok().filter(|i| *i < total_stages))
556            .ok_or_else(|| {
557                BoxError::BuildError(format!("target build stage '{}' not found", target))
558            })?,
559        None => total_stages - 1,
560    };
561
562    // Track completed stages: (alias, rootfs_path)
563    let mut completed_stages: Vec<(Option<String>, PathBuf)> = Vec::new();
564    // Cache external images already pulled+extracted for `COPY --from=<image>`
565    // and RUN mount `from=<image>` sources so repeated references pull once.
566    let mut external_from_rootfs: HashMap<String, PathBuf> = HashMap::new();
567
568    let mut final_state = BuildState::new(config.build_args.clone());
569    let mut final_base_layers: Vec<LayerInfo> = Vec::new();
570    let mut final_base_diff_ids: Vec<String> = Vec::new();
571
572    let total_instructions = dockerfile.instructions.len();
573    let mut global_step = 0;
574    // One cache implementation and one trace span every stage. The trace owns
575    // no blobs or persistence; it only selects exact entries for the terminal
576    // portable artifact.
577    let cache = if config.no_cache {
578        None
579    } else {
580        BuildCache::open()
581    };
582    let mut cache_trace = cache_identity.as_ref().map(|_| BuildCacheTrace::default());
583
584    for (stage_idx, stage) in stages.iter().enumerate() {
585        if let Some(control) = &control {
586            control.ensure_active().await?;
587        }
588        let is_final_stage = stage_idx == output_stage_idx;
589
590        let rootfs_dir = build_dir.join(format!("rootfs_{}", stage_idx));
591        let layers_dir = build_dir.join(format!("layers_{}", stage_idx));
592        std::fs::create_dir_all(&rootfs_dir).map_err(|e| {
593            BoxError::BuildError(format!("Failed to create rootfs directory: {}", e))
594        })?;
595        std::fs::create_dir_all(&layers_dir).map_err(|e| {
596            BoxError::BuildError(format!("Failed to create layers directory: {}", e))
597        })?;
598
599        let mut state = BuildState::new(config.build_args.clone());
600        // Seed later stages with the global pre-FROM ARGs (stage 0 gets them
601        // inline). Without this, a later `FROM image:$GLOBAL_ARG` would not
602        // resolve and the global ARG would be unavailable to the stage body.
603        if stage_idx > 0 {
604            for (name, default) in &global_args {
605                state.seed_global_arg(name, default.as_deref());
606            }
607        }
608        let mut base_layers: Vec<LayerInfo> = Vec::new();
609        let mut base_diff_ids: Vec<String> = Vec::new();
610        // Running chain key over all instructions in this stage. Reset at FROM.
611        let mut chain_key = String::new();
612        // Once a cache miss forces re-execution, all later layers must be rebuilt.
613        let mut cache_valid = true;
614
615        for instruction in &stage.instructions {
616            if let Some(control) = &control {
617                control.ensure_active().await?;
618            }
619            global_step += 1;
620            let step = global_step;
621            validate_instruction_network(instruction, config.network)?;
622            let run_mount_source_roots = if let Instruction::Run {
623                bind_mounts,
624                cache_mounts,
625                ..
626            } = instruction
627            {
628                resolve_run_mount_source_roots(
629                    &completed_stages,
630                    bind_mounts,
631                    cache_mounts,
632                    &store,
633                    build_dir,
634                    &mut external_from_rootfs,
635                )
636                .await?
637            } else {
638                None
639            };
640
641            // Advance the chain key BEFORE the match so a cache-hit `continue`
642            // does not skip it. FROM resets the key (keyed on base content below);
643            // every other instruction extends it, including config-only ones
644            // (ENV/WORKDIR/...) since they affect later RUNs.
645            if !matches!(instruction, Instruction::From { .. }) {
646                // Use build-arg-expanded text in the cache key for instructions
647                // whose effect depends on ARG/--build-arg values, so a different
648                // build arg correctly invalidates downstream layers. (RUN/COPY
649                // paths are not arg-expanded by this engine, so their raw repr is
650                // faithful; build-arg-driven behavior reaches RUN only via ENV.)
651                let repr = match instruction {
652                    Instruction::Env { vars } => {
653                        let pairs: Vec<String> = vars
654                            .iter()
655                            .map(|(k, v)| {
656                                format!("{}={}", k, expand_args(v, &state.expansion_vars()))
657                            })
658                            .collect();
659                        format!("ENV {}", pairs.join(" "))
660                    }
661                    Instruction::Arg { name, default } => {
662                        let effective = state
663                            .build_args
664                            .get(name)
665                            .cloned()
666                            .or_else(|| default.clone())
667                            .unwrap_or_default();
668                        format!("ARG {}={}", name, effective)
669                    }
670                    Instruction::Run { .. } => {
671                        run_instruction_cache_repr(instruction, config.network)
672                    }
673                    other => instruction_to_string(other),
674                };
675                let input_hash = match instruction {
676                    Instruction::Copy {
677                        src, from: None, ..
678                    } => hash_context_sources(&config.context_dir, src),
679                    Instruction::Copy {
680                        src,
681                        from: Some(from_ref),
682                        ..
683                    } => {
684                        // COPY --from=<stage>: key on the ACTUAL source files'
685                        // content in the (already-built) source stage's rootfs.
686                        // Without this the output stage's chain key never depends
687                        // on what the source stage produced, so a changed builder
688                        // binary is served STALE from the on-disk build cache.
689                        // External-image sources resolve to Err here and fall to
690                        // None (the image ref is already in `repr`).
691                        resolve_stage_rootfs(from_ref, &completed_stages)
692                            .ok()
693                            .and_then(|rootfs| hash_context_sources(rootfs, src))
694                    }
695                    Instruction::Add { src, .. } => hash_context_sources(&config.context_dir, src),
696                    Instruction::Run {
697                        cache_mounts,
698                        bind_mounts,
699                        ..
700                    } => run_mount_input_hash(
701                        &config.context_dir,
702                        run_mount_source_roots
703                            .as_deref()
704                            .unwrap_or(&completed_stages),
705                        cache_mounts,
706                        bind_mounts,
707                    ),
708                    _ => None,
709                };
710                chain_key = BuildCache::chain(&chain_key, &repr, input_hash.as_deref());
711            }
712
713            match instruction {
714                Instruction::From { image, alias } => {
715                    if !config.quiet {
716                        if total_stages > 1 {
717                            println!(
718                                "Step {}/{}: FROM {} (stage {}/{}{})",
719                                step,
720                                total_instructions,
721                                image,
722                                stage_idx + 1,
723                                total_stages,
724                                alias
725                                    .as_ref()
726                                    .map(|a| format!(" as {}", a))
727                                    .unwrap_or_default()
728                            );
729                        } else {
730                            println!("Step {}/{}: FROM {}", step, total_instructions, image);
731                        }
732                    }
733                    let (layers, diff_ids, base_config) = handle_from(
734                        image,
735                        &rootfs_dir,
736                        &layers_dir,
737                        &store,
738                        &state.declared_build_args(),
739                    )
740                    .await?;
741                    base_layers = layers;
742                    base_diff_ids = diff_ids;
743
744                    // Key the cache chain on the actual base image content so a
745                    // different base invalidates everything that follows. FROM
746                    // itself is never cached.
747                    chain_key = sha256_bytes(base_diff_ids.join(",").as_bytes());
748                    cache_valid = true;
749
750                    // Inherit config from base image
751                    apply_base_config(&mut state, &base_config);
752
753                    // Execute ONBUILD triggers from base image
754                    if !base_config.onbuild.is_empty() && !config.quiet {
755                        println!(
756                            "  Executing {} ONBUILD trigger(s) from base image",
757                            base_config.onbuild.len()
758                        );
759                    }
760                    for trigger in &base_config.onbuild {
761                        execute_onbuild_trigger(
762                            trigger,
763                            &mut state,
764                            &config,
765                            &rootfs_dir,
766                            &layers_dir,
767                            &base_layers,
768                            &completed_stages,
769                        )?;
770                    }
771
772                    state.history.push(HistoryEntry {
773                        created_by: format!("FROM {}", image),
774                        empty_layer: true,
775                    });
776                }
777
778                Instruction::Copy {
779                    src,
780                    dst,
781                    from,
782                    chown,
783                } => {
784                    let created_by = if let Some(from_ref) = from {
785                        format!("COPY --from={} {} {}", from_ref, src.join(" "), dst)
786                    } else if let Some(owner) = chown {
787                        format!("COPY --chown={} {} {}", owner, src.join(" "), dst)
788                    } else {
789                        format!("COPY {} {}", src.join(" "), dst)
790                    };
791                    if let Some(cached) = try_reuse_cached_layer(
792                        CachedLayerReuse {
793                            cache_valid,
794                            cache: cache.as_ref(),
795                            chain_key: &chain_key,
796                            rootfs_dir: &rootfs_dir,
797                            layers_dir: &layers_dir,
798                            layer_index: state.layers.len() + base_layers.len(),
799                            created_by: &created_by,
800                        },
801                        &mut state,
802                    )? {
803                        if let Some(trace) = &mut cache_trace {
804                            trace.record(&chain_key, &cached)?;
805                        }
806                        if !config.quiet {
807                            println!(
808                                "Step {}/{}: {} (CACHED)",
809                                step, total_instructions, created_by
810                            );
811                        }
812                        continue;
813                    }
814                    cache_valid = false;
815
816                    if let Some(from_ref) = from {
817                        if !config.quiet {
818                            println!(
819                                "Step {}/{}: COPY --from={} {} {}",
820                                step,
821                                total_instructions,
822                                from_ref,
823                                src.join(" "),
824                                dst
825                            );
826                        }
827                        // `--from` is a prior stage (by alias or index) or, like
828                        // Docker, an external image reference to pull and copy
829                        // from.
830                        let from_rootfs: PathBuf =
831                            match resolve_stage_rootfs(from_ref, &completed_stages) {
832                                Ok(stage_rootfs) => stage_rootfs.to_path_buf(),
833                                Err(_) => {
834                                    resolve_external_from_rootfs(
835                                        from_ref,
836                                        "COPY --from",
837                                        &store,
838                                        build_dir,
839                                        &mut external_from_rootfs,
840                                    )
841                                    .await?
842                                }
843                            };
844                        // .dockerignore applies to the build context, not to a
845                        // source stage's rootfs.
846                        let layer_info = handle_copy(
847                            src,
848                            dst,
849                            chown.as_deref(),
850                            &from_rootfs,
851                            &rootfs_dir,
852                            &layers_dir,
853                            &state.workdir,
854                            state.layers.len() + base_layers.len(),
855                            None,
856                        )?;
857                        let diff_id = compute_diff_id(&layer_info.path)?;
858                        store_cache_entry(
859                            cache.as_ref(),
860                            cache_trace.as_mut(),
861                            &chain_key,
862                            &layer_info,
863                            &diff_id,
864                        )?;
865                        state.diff_ids.push(diff_id);
866                        state.layers.push(layer_info);
867                        state.history.push(HistoryEntry {
868                            created_by: format!(
869                                "COPY --from={} {} {}",
870                                from_ref,
871                                src.join(" "),
872                                dst
873                            ),
874                            empty_layer: false,
875                        });
876                    } else {
877                        if !config.quiet {
878                            println!(
879                                "Step {}/{}: COPY {} {}",
880                                step,
881                                total_instructions,
882                                src.join(" "),
883                                dst
884                            );
885                        }
886                        let layer_info = handle_copy(
887                            src,
888                            dst,
889                            chown.as_deref(),
890                            &config.context_dir,
891                            &rootfs_dir,
892                            &layers_dir,
893                            &state.workdir,
894                            state.layers.len() + base_layers.len(),
895                            Some(&dockerignore),
896                        )?;
897                        let diff_id = compute_diff_id(&layer_info.path)?;
898                        store_cache_entry(
899                            cache.as_ref(),
900                            cache_trace.as_mut(),
901                            &chain_key,
902                            &layer_info,
903                            &diff_id,
904                        )?;
905                        state.diff_ids.push(diff_id);
906                        state.layers.push(layer_info);
907                        state.history.push(HistoryEntry {
908                            created_by: format!("COPY {} {}", src.join(" "), dst),
909                            empty_layer: false,
910                        });
911                    }
912                }
913
914                Instruction::Add { src, dst, chown } => {
915                    let created_by = format!("ADD {} {}", src.join(" "), dst);
916                    if let Some(cached) = try_reuse_cached_layer(
917                        CachedLayerReuse {
918                            cache_valid,
919                            cache: cache.as_ref(),
920                            chain_key: &chain_key,
921                            rootfs_dir: &rootfs_dir,
922                            layers_dir: &layers_dir,
923                            layer_index: state.layers.len() + base_layers.len(),
924                            created_by: &created_by,
925                        },
926                        &mut state,
927                    )? {
928                        if let Some(trace) = &mut cache_trace {
929                            trace.record(&chain_key, &cached)?;
930                        }
931                        if !config.quiet {
932                            println!(
933                                "Step {}/{}: {} (CACHED)",
934                                step, total_instructions, created_by
935                            );
936                        }
937                        continue;
938                    }
939                    cache_valid = false;
940
941                    if !config.quiet {
942                        println!(
943                            "Step {}/{}: ADD {} {}",
944                            step,
945                            total_instructions,
946                            src.join(" "),
947                            dst
948                        );
949                    }
950                    let layer_info = handle_add(
951                        src,
952                        dst,
953                        chown.as_deref(),
954                        &config.context_dir,
955                        &rootfs_dir,
956                        &layers_dir,
957                        &state.workdir,
958                        state.layers.len() + base_layers.len(),
959                        Some(&dockerignore),
960                    )?;
961                    let diff_id = compute_diff_id(&layer_info.path)?;
962                    store_cache_entry(
963                        cache.as_ref(),
964                        cache_trace.as_mut(),
965                        &chain_key,
966                        &layer_info,
967                        &diff_id,
968                    )?;
969                    state.diff_ids.push(diff_id);
970                    state.layers.push(layer_info);
971                    state.history.push(HistoryEntry {
972                        created_by: format!("ADD {} {}", src.join(" "), dst),
973                        empty_layer: false,
974                    });
975                }
976
977                Instruction::Run {
978                    command,
979                    cache_mounts,
980                    bind_mounts,
981                    tmpfs_mounts,
982                } => {
983                    let created_by = instruction_to_string(instruction);
984                    if let Some(cached) = try_reuse_cached_layer(
985                        CachedLayerReuse {
986                            cache_valid,
987                            cache: cache.as_ref(),
988                            chain_key: &chain_key,
989                            rootfs_dir: &rootfs_dir,
990                            layers_dir: &layers_dir,
991                            layer_index: state.layers.len() + base_layers.len(),
992                            created_by: &created_by,
993                        },
994                        &mut state,
995                    )? {
996                        if let Some(trace) = &mut cache_trace {
997                            trace.record(&chain_key, &cached)?;
998                        }
999                        if !config.quiet {
1000                            println!(
1001                                "Step {}/{}: {} (CACHED)",
1002                                step, total_instructions, created_by
1003                            );
1004                        }
1005                        continue;
1006                    }
1007                    cache_valid = false;
1008
1009                    if !config.quiet {
1010                        println!("Step {}/{}: {}", step, total_instructions, created_by);
1011                    }
1012                    let layer_opt = if let Some(pool_config) = &config.run_pool {
1013                        // A lease is deliberately scoped to one RUN. The handler
1014                        // destroys its VM before inspecting the shared rootfs, so
1015                        // daemonized descendants cannot race layer capture.
1016                        let session =
1017                            BuildRunPoolSession::acquire(pool_config, &rootfs_dir).await?;
1018                        handle_run_with_pool(
1019                            command,
1020                            cache_mounts,
1021                            bind_mounts,
1022                            tmpfs_mounts,
1023                            &config.context_dir,
1024                            run_mount_source_roots
1025                                .as_deref()
1026                                .unwrap_or(&completed_stages),
1027                            &rootfs_dir,
1028                            &layers_dir,
1029                            &state.workdir,
1030                            &state.run_env(),
1031                            &state.shell,
1032                            state.user.as_deref(),
1033                            state.layers.len() + base_layers.len(),
1034                            config.quiet,
1035                            session,
1036                            Some(&dockerignore),
1037                        )
1038                        .await?
1039                    } else {
1040                        handle_run(
1041                            command,
1042                            cache_mounts,
1043                            bind_mounts,
1044                            tmpfs_mounts,
1045                            config.network,
1046                            &config.context_dir,
1047                            run_mount_source_roots
1048                                .as_deref()
1049                                .unwrap_or(&completed_stages),
1050                            &rootfs_dir,
1051                            &layers_dir,
1052                            &state.workdir,
1053                            &state.run_env(),
1054                            &state.shell,
1055                            state.layers.len() + base_layers.len(),
1056                            config.quiet,
1057                            Some(&dockerignore),
1058                            control.as_ref(),
1059                        )
1060                        .await?
1061                    };
1062                    if let Some(layer_info) = layer_opt {
1063                        let diff_id = compute_diff_id(&layer_info.path)?;
1064                        store_cache_entry(
1065                            cache.as_ref(),
1066                            cache_trace.as_mut(),
1067                            &chain_key,
1068                            &layer_info,
1069                            &diff_id,
1070                        )?;
1071                        state.diff_ids.push(diff_id);
1072                        state.layers.push(layer_info);
1073                        state.history.push(HistoryEntry {
1074                            created_by: created_by.clone(),
1075                            empty_layer: false,
1076                        });
1077                    } else {
1078                        state.history.push(HistoryEntry {
1079                            created_by: created_by.clone(),
1080                            empty_layer: true,
1081                        });
1082                    }
1083                }
1084
1085                Instruction::Workdir { path } => {
1086                    if !config.quiet {
1087                        println!("Step {}/{}: WORKDIR {}", step, total_instructions, path);
1088                    }
1089                    // Expand prior ENV/ARG in the WORKDIR path (Docker does too).
1090                    let expanded_path = expand_args(path, &state.expansion_vars());
1091                    state.workdir = resolve_path(&state.workdir, &expanded_path);
1092                    crate::oci::rootfs::ensure_guest_directory(
1093                        &rootfs_dir,
1094                        state.workdir.trim_start_matches('/'),
1095                    )?;
1096                    state.history.push(HistoryEntry {
1097                        created_by: format!("WORKDIR {}", path),
1098                        empty_layer: true,
1099                    });
1100                }
1101
1102                Instruction::Env { vars } => {
1103                    let display: Vec<String> =
1104                        vars.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
1105                    let display = display.join(" ");
1106                    if !config.quiet {
1107                        println!("Step {}/{}: ENV {}", step, total_instructions, display);
1108                    }
1109                    for (key, value) in vars {
1110                        // Expand prior ENV (and declared ARGs) in the value, left
1111                        // to right, so `ENV A=/x B=$A/y` resolves B against A.
1112                        let expanded_value = expand_args(value, &state.expansion_vars());
1113                        if let Some(existing) = state.env.iter_mut().find(|(k, _)| k == key) {
1114                            existing.1 = expanded_value;
1115                        } else {
1116                            state.env.push((key.clone(), expanded_value));
1117                        }
1118                    }
1119                    state.history.push(HistoryEntry {
1120                        created_by: format!("ENV {}", display),
1121                        empty_layer: true,
1122                    });
1123                }
1124
1125                Instruction::Entrypoint { exec } => {
1126                    if !config.quiet {
1127                        println!(
1128                            "Step {}/{}: ENTRYPOINT {:?}",
1129                            step, total_instructions, exec
1130                        );
1131                    }
1132                    state.entrypoint = Some(exec.clone());
1133                    state.history.push(HistoryEntry {
1134                        created_by: format!("ENTRYPOINT {:?}", exec),
1135                        empty_layer: true,
1136                    });
1137                }
1138
1139                Instruction::Cmd { exec } => {
1140                    if !config.quiet {
1141                        println!("Step {}/{}: CMD {:?}", step, total_instructions, exec);
1142                    }
1143                    state.cmd = Some(exec.clone());
1144                    state.history.push(HistoryEntry {
1145                        created_by: format!("CMD {:?}", exec),
1146                        empty_layer: true,
1147                    });
1148                }
1149
1150                Instruction::Expose { ports } => {
1151                    let joined = ports.join(" ");
1152                    if !config.quiet {
1153                        println!("Step {}/{}: EXPOSE {}", step, total_instructions, joined);
1154                    }
1155                    for port in ports {
1156                        if !state.exposed_ports.contains(port) {
1157                            state.exposed_ports.push(port.clone());
1158                        }
1159                    }
1160                    state.history.push(HistoryEntry {
1161                        created_by: format!("EXPOSE {}", joined),
1162                        empty_layer: true,
1163                    });
1164                }
1165
1166                Instruction::Label { pairs } => {
1167                    let joined = pairs
1168                        .iter()
1169                        .map(|(k, v)| format!("{}={}", k, v))
1170                        .collect::<Vec<_>>()
1171                        .join(" ");
1172                    if !config.quiet {
1173                        println!("Step {}/{}: LABEL {}", step, total_instructions, joined);
1174                    }
1175                    for (key, value) in pairs {
1176                        state.labels.insert(key.clone(), value.clone());
1177                    }
1178                    state.history.push(HistoryEntry {
1179                        created_by: format!("LABEL {}", joined),
1180                        empty_layer: true,
1181                    });
1182                }
1183
1184                Instruction::User { user } => {
1185                    if !config.quiet {
1186                        println!("Step {}/{}: USER {}", step, total_instructions, user);
1187                    }
1188                    state.user = Some(user.clone());
1189                    state.history.push(HistoryEntry {
1190                        created_by: format!("USER {}", user),
1191                        empty_layer: true,
1192                    });
1193                }
1194
1195                Instruction::Arg { name, default } => {
1196                    if !config.quiet {
1197                        println!("Step {}/{}: ARG {}", step, total_instructions, name);
1198                    }
1199                    state.declared_args.insert(name.clone());
1200                    if !state.build_args.contains_key(name) {
1201                        if let Some(val) = default {
1202                            state.build_args.insert(name.clone(), val.clone());
1203                        }
1204                    }
1205                    state.history.push(HistoryEntry {
1206                        created_by: format!("ARG {}", name),
1207                        empty_layer: true,
1208                    });
1209                }
1210
1211                Instruction::Shell { exec } => {
1212                    if !config.quiet {
1213                        println!("Step {}/{}: SHELL {:?}", step, total_instructions, exec);
1214                    }
1215                    state.shell = exec.clone();
1216                    state.history.push(HistoryEntry {
1217                        created_by: format!("SHELL {:?}", exec),
1218                        empty_layer: true,
1219                    });
1220                }
1221
1222                Instruction::StopSignal { signal } => {
1223                    if !config.quiet {
1224                        println!(
1225                            "Step {}/{}: STOPSIGNAL {}",
1226                            step, total_instructions, signal
1227                        );
1228                    }
1229                    state.stop_signal = Some(signal.clone());
1230                    state.history.push(HistoryEntry {
1231                        created_by: format!("STOPSIGNAL {}", signal),
1232                        empty_layer: true,
1233                    });
1234                }
1235
1236                Instruction::HealthCheck {
1237                    cmd,
1238                    interval,
1239                    timeout,
1240                    retries,
1241                    start_period,
1242                } => {
1243                    if !config.quiet {
1244                        if cmd.is_some() {
1245                            println!("Step {}/{}: HEALTHCHECK CMD ...", step, total_instructions);
1246                        } else {
1247                            println!("Step {}/{}: HEALTHCHECK NONE", step, total_instructions);
1248                        }
1249                    }
1250                    state.health_check = cmd.as_ref().map(|c| OciHealthCheck {
1251                        test: c.clone(),
1252                        interval: *interval,
1253                        timeout: *timeout,
1254                        retries: *retries,
1255                        start_period: *start_period,
1256                    });
1257                    state.history.push(HistoryEntry {
1258                        created_by: if cmd.is_some() {
1259                            "HEALTHCHECK CMD ...".to_string()
1260                        } else {
1261                            "HEALTHCHECK NONE".to_string()
1262                        },
1263                        empty_layer: true,
1264                    });
1265                }
1266
1267                Instruction::OnBuild { instruction } => {
1268                    let trigger = format!("{:?}", instruction);
1269                    if !config.quiet {
1270                        println!("Step {}/{}: ONBUILD {}", step, total_instructions, trigger);
1271                    }
1272                    // Store the raw instruction text for the image config
1273                    state.onbuild.push(instruction_to_string(instruction));
1274                    state.history.push(HistoryEntry {
1275                        created_by: format!("ONBUILD {}", instruction_to_string(instruction)),
1276                        empty_layer: true,
1277                    });
1278                }
1279
1280                Instruction::Volume { paths } => {
1281                    if !config.quiet {
1282                        println!(
1283                            "Step {}/{}: VOLUME {}",
1284                            step,
1285                            total_instructions,
1286                            paths.join(" ")
1287                        );
1288                    }
1289                    for p in paths {
1290                        if !state.volumes.contains(p) {
1291                            state.volumes.push(p.clone());
1292                        }
1293                    }
1294                    // Create volume directories in rootfs
1295                    for p in paths {
1296                        crate::oci::rootfs::ensure_guest_directory(
1297                            &rootfs_dir,
1298                            p.trim_start_matches('/'),
1299                        )?;
1300                    }
1301                    state.history.push(HistoryEntry {
1302                        created_by: format!("VOLUME {}", paths.join(" ")),
1303                        empty_layer: true,
1304                    });
1305                }
1306            }
1307        }
1308
1309        // Store completed stage rootfs for COPY --from
1310        completed_stages.push((stage.alias.clone(), rootfs_dir.clone()));
1311
1312        if is_final_stage {
1313            final_state = state;
1314            final_base_layers = base_layers;
1315            final_base_diff_ids = base_diff_ids;
1316            // Stages after the --target stage are not part of the output; stop.
1317            break;
1318        }
1319    }
1320
1321    // Assemble the final OCI image from the output (final or --target) stage
1322    let reference = config
1323        .tag
1324        .clone()
1325        .unwrap_or_else(|| "a3s-build:latest".to_string());
1326
1327    let final_layers_dir = build_dir.join(format!("layers_{}", output_stage_idx));
1328
1329    // Determine target platform (use first platform or host default)
1330    let target_platform = config
1331        .platforms
1332        .first()
1333        .cloned()
1334        .unwrap_or_else(default_target_platform);
1335
1336    if let Some(control) = &control {
1337        control.ensure_active().await?;
1338    }
1339    let staged_cache = match cache_identity.as_ref() {
1340        Some(identity) => {
1341            let cache = cache.as_ref().ok_or_else(|| {
1342                BoxError::BuildError(
1343                    "content-addressed build cache could not be opened for export".to_string(),
1344                )
1345            })?;
1346            let trace = cache_trace.as_ref().ok_or_else(|| {
1347                BoxError::BuildError(
1348                    "content-addressed build cache export lost its native trace".to_string(),
1349                )
1350            })?;
1351            Some(cache.stage_export(trace, identity, &build_dir.join("_cache_export"))?)
1352        }
1353        None => None,
1354    };
1355    let result = assemble_image(
1356        &reference,
1357        &final_state,
1358        &final_base_layers,
1359        &final_base_diff_ids,
1360        &final_layers_dir,
1361        &store,
1362        &target_platform,
1363        control.as_ref(),
1364        staged_cache,
1365    )
1366    .await?;
1367
1368    if !config.quiet {
1369        println!(
1370            "Successfully built {} ({} layers, {}, {})",
1371            reference,
1372            result.output.layer_count,
1373            format_size(result.output.size),
1374            target_platform,
1375        );
1376    }
1377
1378    if let Some(ref m) = config.metrics {
1379        m.image_build_total.inc();
1380    }
1381
1382    Ok(result)
1383}
1384
1385// =============================================================================
1386// Helper functions
1387// =============================================================================
1388
1389fn store_cache_entry(
1390    cache: Option<&BuildCache>,
1391    trace: Option<&mut BuildCacheTrace>,
1392    chain_key: &str,
1393    layer: &LayerInfo,
1394    diff_id: &str,
1395) -> Result<()> {
1396    let Some(cache) = cache else {
1397        if trace.is_some() {
1398            return Err(BoxError::BuildError(
1399                "content-addressed build cache could not be opened".to_string(),
1400            ));
1401        }
1402        return Ok(());
1403    };
1404    cache.store(chain_key, layer, diff_id);
1405    if let Some(trace) = trace {
1406        let cached = cache.lookup(chain_key).ok_or_else(|| {
1407            BoxError::BuildError(format!(
1408                "content-addressed build cache did not retain chain key sha256:{chain_key}"
1409            ))
1410        })?;
1411        trace.record(chain_key, &cached)?;
1412    }
1413    Ok(())
1414}
1415
1416/// Attempt to reuse a cached layer for a layer-producing instruction.
1417///
1418/// On a cache hit (and only when `cache_valid` is still true and a cache is
1419/// open), this applies the cached layer's diff to `rootfs_dir` so later
1420/// instructions build on the correct rootfs, then records the layer, diff_id,
1421/// and a non-empty history entry in `state`. Returns the verified cache entry
1422/// on a hit (the
1423/// caller should `continue`), or `None` to fall through to normal execution.
1424struct CachedLayerReuse<'a> {
1425    cache_valid: bool,
1426    cache: Option<&'a BuildCache>,
1427    chain_key: &'a str,
1428    rootfs_dir: &'a Path,
1429    layers_dir: &'a Path,
1430    layer_index: usize,
1431    created_by: &'a str,
1432}
1433
1434fn try_reuse_cached_layer(
1435    request: CachedLayerReuse<'_>,
1436    state: &mut BuildState,
1437) -> Result<Option<CachedLayer>> {
1438    if !request.cache_valid {
1439        return Ok(None);
1440    }
1441    let Some(cached) = request.cache.and_then(|c| c.lookup(request.chain_key)) else {
1442        return Ok(None);
1443    };
1444
1445    let local_layer = request.layers_dir.join(format!(
1446        "cached_{}_{}.tar.gz",
1447        request.layer_index, cached.digest
1448    ));
1449    if let Err(error) = std::fs::copy(&cached.blob_path, &local_layer) {
1450        tracing::warn!(
1451            key = %request.chain_key,
1452            source = %cached.blob_path.display(),
1453            error = %error,
1454            "Build cache blob disappeared before it could be materialized; rebuilding instruction"
1455        );
1456        return Ok(None);
1457    }
1458
1459    // Apply the cached diff so subsequent instructions see the right rootfs.
1460    extract_layer(&local_layer, request.rootfs_dir)?;
1461    let local_size = std::fs::metadata(&local_layer)
1462        .map(|metadata| metadata.len())
1463        .unwrap_or(cached.size);
1464
1465    state.layers.push(LayerInfo {
1466        path: local_layer,
1467        digest: cached.digest.clone(),
1468        size: local_size,
1469    });
1470    state.diff_ids.push(cached.diff_id.clone());
1471    state.history.push(HistoryEntry {
1472        created_by: request.created_by.to_string(),
1473        empty_layer: false,
1474    });
1475    Ok(Some(cached))
1476}
1477
1478/// Handle FROM: pull base image and extract layers into rootfs.
1479///
1480/// Returns (base_layers, base_diff_ids, base_config).
1481async fn handle_from(
1482    image: &str,
1483    rootfs_dir: &Path,
1484    _layers_dir: &Path,
1485    store: &Arc<ImageStore>,
1486    build_args: &HashMap<String, String>,
1487) -> Result<(Vec<LayerInfo>, Vec<String>, OciImageConfig)> {
1488    let image_ref = expand_args(image, build_args);
1489    if image_ref == "scratch" {
1490        return Ok((Vec::new(), Vec::new(), scratch_config()));
1491    }
1492
1493    // Pull the base image
1494    let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1495    let oci_image = puller.pull(&image_ref).await?;
1496
1497    // Extract all layers into rootfs
1498    for layer_path in oci_image.layer_paths() {
1499        extract_layer(layer_path, rootfs_dir)?;
1500    }
1501
1502    // Collect base layer info
1503    let mut base_layers = Vec::new();
1504    let mut base_diff_ids = Vec::new();
1505
1506    for layer_path in oci_image.layer_paths() {
1507        let digest = sha256_file(layer_path)?;
1508        let size = std::fs::metadata(layer_path).map(|m| m.len()).unwrap_or(0);
1509
1510        // Compute diff_id (SHA256 of uncompressed content)
1511        let diff_id = compute_diff_id(layer_path)?;
1512        base_diff_ids.push(diff_id);
1513
1514        base_layers.push(LayerInfo {
1515            path: layer_path.to_path_buf(),
1516            digest,
1517            size,
1518        });
1519    }
1520
1521    let config = oci_image.config().clone();
1522    Ok((base_layers, base_diff_ids, config))
1523}
1524
1525/// Resolve an external image source when `from=<image>` is not a build stage:
1526/// pull the image and extract it to a temp rootfs (Docker behavior). Memoized
1527/// per build so several copies or RUN bind mounts from one image pull only once.
1528async fn resolve_external_from_rootfs(
1529    image_ref: &str,
1530    operation: &str,
1531    store: &Arc<ImageStore>,
1532    build_dir: &Path,
1533    cache: &mut HashMap<String, PathBuf>,
1534) -> Result<PathBuf> {
1535    if let Some(dir) = cache.get(image_ref) {
1536        return Ok(dir.clone());
1537    }
1538
1539    let dir = build_dir.join(format!("copyfrom_{}", cache.len()));
1540    std::fs::create_dir_all(&dir).map_err(|e| {
1541        BoxError::BuildError(format!(
1542            "Failed to create {operation} image rootfs {}: {}",
1543            dir.display(),
1544            e
1545        ))
1546    })?;
1547
1548    let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env());
1549    let oci_image = puller.pull(image_ref).await.map_err(|e| {
1550        BoxError::BuildError(format!(
1551            "{operation} from={}: not a build stage and could not be pulled as an image: {}",
1552            image_ref, e
1553        ))
1554    })?;
1555    for layer_path in oci_image.layer_paths() {
1556        extract_layer(layer_path, &dir)?;
1557    }
1558
1559    cache.insert(image_ref.to_string(), dir.clone());
1560    Ok(dir)
1561}
1562
1563fn validate_build_config(config: &BuildConfig) -> Result<()> {
1564    if config.platforms.len() > 1 {
1565        return Err(BoxError::BuildError(
1566            "Multi-platform builds are not implemented yet; pass a single target platform"
1567                .to_string(),
1568        ));
1569    }
1570
1571    for platform in &config.platforms {
1572        if platform.os != "linux" {
1573            return Err(BoxError::BuildError(format!(
1574                "Only linux target platforms are supported for image builds, got {}",
1575                platform
1576            )));
1577        }
1578    }
1579
1580    if config.network == BuildNetworkPolicy::None && config.run_pool.is_some() {
1581        return Err(BoxError::BuildError(
1582            "network-none builds cannot use a warm RUN pool until the pool provides a generation-bound isolated network namespace"
1583                .to_string(),
1584        ));
1585    }
1586
1587    Ok(())
1588}
1589
1590fn validate_instruction_network(
1591    instruction: &Instruction,
1592    network: BuildNetworkPolicy,
1593) -> Result<()> {
1594    if network != BuildNetworkPolicy::None {
1595        return Ok(());
1596    }
1597    if let Instruction::Add { src, .. } = instruction {
1598        if src
1599            .iter()
1600            .any(|source| source.starts_with("http://") || source.starts_with("https://"))
1601        {
1602            return Err(BoxError::BuildError(
1603                "network-none builds reject remote URL ADD; materialize the input as a content-addressed build-context artifact"
1604                    .to_string(),
1605            ));
1606        }
1607    }
1608    Ok(())
1609}
1610
1611fn run_instruction_cache_repr(instruction: &Instruction, network: BuildNetworkPolicy) -> String {
1612    format!(
1613        "{}\n#a3s.box.build.network={}",
1614        instruction_to_string(instruction),
1615        network.as_acl()
1616    )
1617}
1618
1619fn default_target_platform() -> Platform {
1620    let host = Platform::host();
1621    Platform::new("linux", host.architecture)
1622}
1623
1624fn scratch_config() -> OciImageConfig {
1625    OciImageConfig {
1626        entrypoint: None,
1627        cmd: None,
1628        env: Vec::new(),
1629        working_dir: None,
1630        user: None,
1631        exposed_ports: Vec::new(),
1632        labels: HashMap::new(),
1633        volumes: Vec::new(),
1634        stop_signal: None,
1635        health_check: None,
1636        onbuild: Vec::new(),
1637    }
1638}
1639
1640/// Assemble the final OCI image layout and store it.
1641#[allow(clippy::too_many_arguments)]
1642async fn assemble_image(
1643    reference: &str,
1644    state: &BuildState,
1645    base_layers: &[LayerInfo],
1646    base_diff_ids: &[String],
1647    layers_dir: &Path,
1648    store: &Arc<ImageStore>,
1649    target_platform: &Platform,
1650    control: Option<&BuildExecutionControl>,
1651    staged_cache: Option<RecordedBuildCache>,
1652) -> Result<SupervisedBuildResult> {
1653    // Create output directory
1654    let output_dir = layers_dir.join("_output");
1655    let blobs_dir = output_dir.join("blobs").join("sha256");
1656    std::fs::create_dir_all(&blobs_dir)
1657        .map_err(|e| BoxError::BuildError(format!("Failed to create output blobs dir: {}", e)))?;
1658
1659    // Collect all layers: base + new
1660    let mut all_layer_descriptors = Vec::new();
1661    let mut all_diff_ids: Vec<String> = base_diff_ids.to_vec();
1662
1663    // Copy base layers to output
1664    for layer in base_layers {
1665        let blob_path = blobs_dir.join(&layer.digest);
1666        if !blob_path.exists() {
1667            copy_layer_blob(layer, &blob_path, "base layer")?;
1668        }
1669        all_layer_descriptors.push(serde_json::json!({
1670            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1671            "digest": layer.prefixed_digest(),
1672            "size": layer.size
1673        }));
1674    }
1675
1676    // Copy new layers to output
1677    for (i, layer) in state.layers.iter().enumerate() {
1678        let blob_path = blobs_dir.join(&layer.digest);
1679        if !blob_path.exists() {
1680            copy_layer_blob(layer, &blob_path, &format!("layer {i}"))?;
1681        }
1682        all_layer_descriptors.push(serde_json::json!({
1683            "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
1684            "digest": layer.prefixed_digest(),
1685            "size": layer.size
1686        }));
1687    }
1688
1689    // Merge diff_ids
1690    all_diff_ids.extend(state.diff_ids.iter().cloned());
1691
1692    // Build OCI config
1693    let arch = target_platform.oci_arch();
1694
1695    let env_list: Vec<String> = state
1696        .env
1697        .iter()
1698        .map(|(k, v)| format!("{}={}", k, v))
1699        .collect();
1700
1701    let mut config_obj = serde_json::json!({
1702        "architecture": arch,
1703        "os": "linux",
1704        "created": REPRODUCIBLE_OCI_CREATED_AT,
1705        "config": {},
1706        "rootfs": {
1707            "type": "layers",
1708            "diff_ids": all_diff_ids.iter()
1709                .map(|d| format!("sha256:{}", d))
1710                .collect::<Vec<_>>()
1711        },
1712        "history": state.history.iter().map(|h| {
1713            let mut entry = serde_json::json!({
1714                "created": REPRODUCIBLE_OCI_CREATED_AT,
1715                "created_by": h.created_by
1716            });
1717            if h.empty_layer {
1718                entry["empty_layer"] = serde_json::json!(true);
1719            }
1720            entry
1721        }).collect::<Vec<_>>()
1722    });
1723    if let Some(variant) = &target_platform.variant {
1724        config_obj["variant"] = serde_json::json!(variant);
1725    }
1726
1727    // Populate config section
1728    let config_section = config_obj["config"].as_object_mut().unwrap();
1729    if !env_list.is_empty() {
1730        config_section.insert("Env".to_string(), serde_json::json!(env_list));
1731    }
1732    if let Some(ref ep) = state.entrypoint {
1733        config_section.insert("Entrypoint".to_string(), serde_json::json!(ep));
1734    }
1735    if let Some(ref cmd) = state.cmd {
1736        config_section.insert("Cmd".to_string(), serde_json::json!(cmd));
1737    }
1738    if state.workdir != "/" {
1739        config_section.insert("WorkingDir".to_string(), serde_json::json!(state.workdir));
1740    }
1741    if let Some(ref user) = state.user {
1742        config_section.insert("User".to_string(), serde_json::json!(user));
1743    }
1744    if !state.exposed_ports.is_empty() {
1745        let ports: HashMap<String, serde_json::Value> = state
1746            .exposed_ports
1747            .iter()
1748            .map(|p| (p.clone(), serde_json::json!({})))
1749            .collect();
1750        config_section.insert("ExposedPorts".to_string(), serde_json::json!(ports));
1751    }
1752    if !state.labels.is_empty() {
1753        config_section.insert("Labels".to_string(), serde_json::json!(state.labels));
1754    }
1755    if let Some(ref sig) = state.stop_signal {
1756        config_section.insert("StopSignal".to_string(), serde_json::json!(sig));
1757    }
1758    if let Some(ref hc) = state.health_check {
1759        let mut hc_obj = serde_json::json!({
1760            "Test": hc.test,
1761        });
1762        if let Some(interval) = hc.interval {
1763            // OCI stores intervals in nanoseconds
1764            hc_obj["Interval"] = serde_json::json!(interval * 1_000_000_000);
1765        }
1766        if let Some(timeout) = hc.timeout {
1767            hc_obj["Timeout"] = serde_json::json!(timeout * 1_000_000_000);
1768        }
1769        if let Some(retries) = hc.retries {
1770            hc_obj["Retries"] = serde_json::json!(retries);
1771        }
1772        if let Some(start_period) = hc.start_period {
1773            hc_obj["StartPeriod"] = serde_json::json!(start_period * 1_000_000_000);
1774        }
1775        config_section.insert("Healthcheck".to_string(), hc_obj);
1776    }
1777    if !state.onbuild.is_empty() {
1778        config_section.insert("OnBuild".to_string(), serde_json::json!(state.onbuild));
1779    }
1780    if !state.volumes.is_empty() {
1781        let vols: HashMap<String, serde_json::Value> = state
1782            .volumes
1783            .iter()
1784            .map(|v| (v.clone(), serde_json::json!({})))
1785            .collect();
1786        config_section.insert("Volumes".to_string(), serde_json::json!(vols));
1787    }
1788
1789    // Write config blob
1790    let config_bytes = serde_json::to_vec_pretty(&config_obj)?;
1791    let config_digest = sha256_bytes(&config_bytes);
1792    std::fs::write(blobs_dir.join(&config_digest), &config_bytes)
1793        .map_err(|e| BoxError::BuildError(format!("Failed to write config blob: {}", e)))?;
1794
1795    // Build manifest
1796    let manifest = serde_json::json!({
1797        "schemaVersion": 2,
1798        "mediaType": OCI_IMAGE_MANIFEST_MEDIA_TYPE,
1799        "config": {
1800            "mediaType": "application/vnd.oci.image.config.v1+json",
1801            "digest": format!("sha256:{}", config_digest),
1802            "size": config_bytes.len()
1803        },
1804        "layers": all_layer_descriptors
1805    });
1806
1807    let manifest_bytes = serde_json::to_vec_pretty(&manifest)?;
1808    let manifest_digest = sha256_bytes(&manifest_bytes);
1809    std::fs::write(blobs_dir.join(&manifest_digest), &manifest_bytes)
1810        .map_err(|e| BoxError::BuildError(format!("Failed to write manifest blob: {}", e)))?;
1811
1812    // Write index.json
1813    let mut platform_obj = serde_json::json!({
1814        "os": target_platform.os,
1815        "architecture": target_platform.architecture
1816    });
1817    if let Some(ref variant) = target_platform.variant {
1818        platform_obj["variant"] = serde_json::json!(variant);
1819    }
1820
1821    let index = serde_json::json!({
1822        "schemaVersion": 2,
1823        "mediaType": "application/vnd.oci.image.index.v1+json",
1824        "manifests": [{
1825            "mediaType": OCI_IMAGE_MANIFEST_MEDIA_TYPE,
1826            "digest": format!("sha256:{}", manifest_digest),
1827            "size": manifest_bytes.len(),
1828            "platform": platform_obj
1829        }]
1830    });
1831    std::fs::write(
1832        output_dir.join("index.json"),
1833        serde_json::to_string_pretty(&index)?,
1834    )
1835    .map_err(|e| BoxError::BuildError(format!("Failed to write index.json: {}", e)))?;
1836
1837    // Write oci-layout
1838    std::fs::write(
1839        output_dir.join("oci-layout"),
1840        r#"{"imageLayoutVersion":"1.0.0"}"#,
1841    )
1842    .map_err(|e| BoxError::BuildError(format!("Failed to write oci-layout: {}", e)))?;
1843
1844    // Store in image store
1845    let digest_str = format!("sha256:{}", manifest_digest);
1846    let _commit_permit = match control {
1847        Some(control) => Some(control.acquire_image_commit_permit().await?),
1848        None => None,
1849    };
1850    let cache = match staged_cache {
1851        Some(staged) => {
1852            let control = control.ok_or_else(|| {
1853                BoxError::BuildError(
1854                    "native cache export requires the recorded-build journal".to_string(),
1855                )
1856            })?;
1857            Some(control.publish_cache_export(staged).await?)
1858        }
1859        None => None,
1860    };
1861    let output =
1862        publish_single_build_output(reference, &digest_str, &output_dir, store, target_platform)
1863            .await?;
1864    Ok(SupervisedBuildResult { output, cache })
1865}
1866
1867fn copy_layer_blob(layer: &LayerInfo, blob_path: &Path, label: &str) -> Result<()> {
1868    if !layer.path.exists() {
1869        return Err(BoxError::BuildError(format!(
1870            "Failed to copy {label}: source layer {} for digest {} does not exist",
1871            layer.path.display(),
1872            layer.prefixed_digest()
1873        )));
1874    }
1875
1876    std::fs::copy(&layer.path, blob_path).map_err(|e| {
1877        BoxError::BuildError(format!(
1878            "Failed to copy {label} from {} to {} (digest {}): {}",
1879            layer.path.display(),
1880            blob_path.display(),
1881            layer.prefixed_digest(),
1882            e
1883        ))
1884    })?;
1885    Ok(())
1886}