Skip to main content

a3s_box_runtime/oci/build/
assembly.rs

1//! Deterministic OCI index assembly from recorded native build outputs.
2
3use std::fs::OpenOptions;
4use std::io;
5use std::path::{Path, PathBuf};
6use std::str::FromStr;
7use std::sync::Arc;
8
9use a3s_box_core::error::{BoxError, Result as BoxResult};
10use a3s_box_core::platform::Platform;
11use oci_spec::image::{
12    Arch, DescriptorBuilder, ImageIndexBuilder, MediaType, Os, PlatformBuilder, Sha256Digest,
13    SCHEMA_VERSION,
14};
15use thiserror::Error;
16
17use super::layer::sha256_bytes;
18use super::output::publish_multi_platform_build_output;
19use super::{
20    BoxBuildPlan, BoxBuildPlanError, BuildOperationIdentity, BuildOutputReceipt, BuildReceiptError,
21    MultiPlatformBuildResult,
22};
23use crate::oci::image::{
24    canonical_sha256_digest_hex, open_regular_file_no_follow, validate_plain_directory,
25};
26use crate::oci::ImageStore;
27
28const MAX_ASSEMBLY_INPUTS: usize = 8;
29const MAX_REFERENCE_BYTES: usize = 4096;
30
31/// One exact single-platform plan and its durable recorded output.
32#[derive(Debug, Clone)]
33pub struct BuildOutputAssemblyInput {
34    plan: BoxBuildPlan,
35    receipt: BuildOutputReceipt,
36}
37
38impl BuildOutputAssemblyInput {
39    /// Bind one immutable plan to the receipt that claims its output.
40    pub const fn new(plan: BoxBuildPlan, receipt: BuildOutputReceipt) -> Self {
41        Self { plan, receipt }
42    }
43
44    /// Exact single-platform build plan.
45    pub const fn plan(&self) -> &BoxBuildPlan {
46        &self.plan
47    }
48
49    /// Durable single-platform output receipt.
50    pub const fn receipt(&self) -> &BuildOutputReceipt {
51        &self.receipt
52    }
53}
54
55/// Canonical, stateless request to assemble recorded outputs into one index.
56///
57/// This value owns no execution, cache, queue, journal, or publication state.
58/// Construction proves that all inputs describe the same build intent and
59/// source, differing only by their unique target platform.
60#[derive(Debug, Clone)]
61pub struct BuildOutputAssembly {
62    reference: String,
63    source_digest: String,
64    inputs: Vec<BuildOutputAssemblyInput>,
65}
66
67impl BuildOutputAssembly {
68    /// Validate and canonically sort one bounded multi-platform assembly.
69    pub fn new(
70        reference: impl Into<String>,
71        source_digest: impl Into<String>,
72        mut inputs: Vec<BuildOutputAssemblyInput>,
73    ) -> Result<Self, BuildAssemblyError> {
74        let reference = reference.into();
75        validate_reference(&reference)?;
76        let source_digest = source_digest.into();
77        canonical_sha256_digest_hex(&source_digest)
78            .map_err(|_| BuildAssemblyError::invalid("source digest must be canonical SHA-256"))?;
79        if !(2..=MAX_ASSEMBLY_INPUTS).contains(&inputs.len()) {
80            return Err(BuildAssemblyError::invalid(
81                "assembly requires between two and eight recorded platforms",
82            ));
83        }
84        inputs.sort_by(|left, right| {
85            left.plan
86                .platform()
87                .to_string()
88                .cmp(&right.plan.platform().to_string())
89        });
90
91        let baseline = inputs
92            .first()
93            .ok_or_else(|| BuildAssemblyError::invalid("assembly omitted its inputs"))?;
94        for pair in inputs.windows(2) {
95            if pair[0].plan.platform() == pair[1].plan.platform() {
96                return Err(BuildAssemblyError::invalid(
97                    "assembly platforms must be unique",
98                ));
99            }
100        }
101        for input in &inputs {
102            if !baseline.plan.has_same_non_platform_intent(&input.plan) {
103                return Err(BuildAssemblyError::invalid(
104                    "assembly plans must have identical non-platform build intent",
105                ));
106            }
107            let plan_digest = input.plan.canonical_digest()?;
108            if input.receipt.source_digest != source_digest {
109                return Err(BuildAssemblyError::invalid(
110                    "assembly receipt source differs from the admitted source",
111                ));
112            }
113            if input.receipt.plan_digest != plan_digest
114                || input.receipt.output.platform != *input.plan.platform()
115            {
116                return Err(BuildAssemblyError::invalid(
117                    "assembly receipt does not match its exact single-platform plan",
118                ));
119            }
120            if input.receipt.output.reference == reference {
121                return Err(BuildAssemblyError::invalid(
122                    "assembly target cannot replace an input receipt reference",
123                ));
124            }
125            let identity = BuildOperationIdentity::new(
126                input.receipt.operation_id.clone(),
127                source_digest.clone(),
128            )?;
129            input
130                .receipt
131                .require_identity(&identity, &plan_digest, input.plan.cache())?;
132        }
133
134        Ok(Self {
135            reference,
136            source_digest,
137            inputs,
138        })
139    }
140
141    /// Destination reference in the one Box image store.
142    pub fn reference(&self) -> &str {
143        &self.reference
144    }
145
146    /// Immutable source Artifact digest shared by every input.
147    pub fn source_digest(&self) -> &str {
148        &self.source_digest
149    }
150
151    /// Canonically platform-sorted plan and receipt inputs.
152    pub fn inputs(&self) -> &[BuildOutputAssemblyInput] {
153        &self.inputs
154    }
155
156    fn platforms(&self) -> Vec<Platform> {
157        self.inputs
158            .iter()
159            .map(|input| input.plan.platform().clone())
160            .collect()
161    }
162}
163
164/// Stable validation and publication failures for OCI index assembly.
165#[derive(Debug, Error)]
166pub enum BuildAssemblyError {
167    /// The stateless assembly contract rejected inconsistent input.
168    #[error("Box build output assembly is invalid: {message}")]
169    Invalid { message: String },
170    /// One canonical single-platform plan could not be reconstructed.
171    #[error(transparent)]
172    Plan(#[from] BoxBuildPlanError),
173    /// One durable receipt or its ImageStore output failed revalidation.
174    #[error(transparent)]
175    Receipt(#[from] BuildReceiptError),
176    /// Layout staging or the sole ImageStore publication boundary failed.
177    #[error(transparent)]
178    Build(#[from] BoxError),
179}
180
181impl BuildAssemblyError {
182    fn invalid(message: impl Into<String>) -> Self {
183        Self::Invalid {
184            message: message.into(),
185        }
186    }
187}
188
189/// Assemble already recorded single-platform outputs into one deterministic
190/// OCI image index and publish it through the existing [`ImageStore`].
191///
192/// Every receipt is completely revalidated before staging begins. The staged
193/// graph then passes the same native output validator as a direct build before
194/// the sole ImageStore commit boundary is entered.
195pub async fn assemble_recorded_build_outputs(
196    assembly: &BuildOutputAssembly,
197    store: Arc<ImageStore>,
198) -> Result<MultiPlatformBuildResult, BuildAssemblyError> {
199    let resolved = resolve_assembly_inputs(assembly, &store).await?;
200    let staged = tokio::task::spawn_blocking(move || stage_assembly(resolved))
201        .await
202        .map_err(|error| {
203            BoxError::BuildError(format!("OCI index assembly task failed: {error}"))
204        })??;
205
206    // Close the validation-to-copy gap against concurrent ImageStore
207    // tampering or removal. Changes after this pass cannot alter the staged
208    // copy, which is independently validated before publication.
209    let _ = resolve_assembly_inputs(assembly, &store).await?;
210    let platforms = assembly.platforms();
211    publish_multi_platform_build_output(
212        assembly.reference(),
213        &staged.digest,
214        staged.directory.path(),
215        &store,
216        &platforms,
217    )
218    .await
219    .map_err(BuildAssemblyError::from)
220}
221
222async fn resolve_assembly_inputs(
223    assembly: &BuildOutputAssembly,
224    store: &ImageStore,
225) -> Result<Vec<ResolvedAssemblyInput>, BuildAssemblyError> {
226    let mut resolved = Vec::with_capacity(assembly.inputs.len());
227    for input in &assembly.inputs {
228        let output = input.receipt.resolve(store).await?;
229        if output.platform != *input.plan.platform() {
230            return Err(BuildAssemblyError::invalid(
231                "revalidated output platform differs from its assembly plan",
232            ));
233        }
234        resolved.push(ResolvedAssemblyInput {
235            platform: output.platform,
236            descriptor: output.descriptor,
237            layout_directory: output.layout_directory,
238        });
239    }
240    Ok(resolved)
241}
242
243struct ResolvedAssemblyInput {
244    platform: Platform,
245    descriptor: super::BuildOutputDescriptor,
246    layout_directory: PathBuf,
247}
248
249struct StagedAssembly {
250    directory: tempfile::TempDir,
251    digest: String,
252}
253
254fn stage_assembly(inputs: Vec<ResolvedAssemblyInput>) -> BoxResult<StagedAssembly> {
255    let directory = tempfile::Builder::new()
256        .prefix("a3s-box-build-index-")
257        .tempdir()
258        .map_err(|error| {
259            BoxError::BuildError(format!(
260                "failed to create OCI index staging directory: {error}"
261            ))
262        })?;
263    let blob_root = directory.path().join("blobs").join("sha256");
264    std::fs::create_dir_all(&blob_root).map_err(|error| {
265        BoxError::BuildError(format!(
266            "failed to create OCI index blob directory: {error}"
267        ))
268    })?;
269
270    let mut manifests = Vec::with_capacity(inputs.len());
271    for input in inputs {
272        copy_recorded_build_blobs(&input.layout_directory, &blob_root)?;
273        let digest_hex = canonical_sha256_digest_hex(&input.descriptor.digest)?;
274        let mut platform = PlatformBuilder::default()
275            .architecture(Arch::from(input.platform.architecture.as_str()))
276            .os(Os::from(input.platform.os.as_str()));
277        if let Some(variant) = input.platform.variant {
278            platform = platform.variant(variant);
279        }
280        manifests.push(
281            DescriptorBuilder::default()
282                .media_type(MediaType::ImageManifest)
283                .digest(parse_digest(digest_hex)?)
284                .size(input.descriptor.size)
285                .platform(platform.build().map_err(|error| {
286                    BoxError::BuildError(format!("invalid assembly platform: {error}"))
287                })?)
288                .build()
289                .map_err(|error| {
290                    BoxError::BuildError(format!("invalid assembly manifest descriptor: {error}"))
291                })?,
292        );
293    }
294
295    let image_index = ImageIndexBuilder::default()
296        .schema_version(SCHEMA_VERSION)
297        .media_type(MediaType::ImageIndex)
298        .manifests(manifests)
299        .build()
300        .map_err(|error| {
301            BoxError::BuildError(format!(
302                "failed to build multi-platform image index: {error}"
303            ))
304        })?;
305    let image_index_bytes = serde_json::to_vec(&image_index).map_err(|error| {
306        BoxError::BuildError(format!(
307            "failed to encode multi-platform image index: {error}"
308        ))
309    })?;
310    let image_index_hex = sha256_bytes(&image_index_bytes);
311    write_new_blob(&blob_root.join(&image_index_hex), &image_index_bytes)?;
312    let root_descriptor = DescriptorBuilder::default()
313        .media_type(MediaType::ImageIndex)
314        .digest(parse_digest(&image_index_hex)?)
315        .size(image_index_bytes.len() as u64)
316        .build()
317        .map_err(|error| {
318            BoxError::BuildError(format!("invalid multi-platform root descriptor: {error}"))
319        })?;
320    let layout_index = ImageIndexBuilder::default()
321        .schema_version(SCHEMA_VERSION)
322        .media_type(MediaType::ImageIndex)
323        .manifests(vec![root_descriptor])
324        .build()
325        .map_err(|error| {
326            BoxError::BuildError(format!("failed to build OCI layout index: {error}"))
327        })?;
328    std::fs::write(
329        directory.path().join("index.json"),
330        serde_json::to_vec(&layout_index).map_err(|error| {
331            BoxError::BuildError(format!("failed to encode OCI layout index: {error}"))
332        })?,
333    )
334    .map_err(|error| BoxError::BuildError(format!("failed to write OCI layout index: {error}")))?;
335    std::fs::write(
336        directory.path().join("oci-layout"),
337        br#"{"imageLayoutVersion":"1.0.0"}"#,
338    )
339    .map_err(|error| BoxError::BuildError(format!("failed to write OCI layout marker: {error}")))?;
340
341    Ok(StagedAssembly {
342        directory,
343        digest: format!("sha256:{image_index_hex}"),
344    })
345}
346
347fn copy_recorded_build_blobs(source_layout: &Path, target: &Path) -> BoxResult<()> {
348    let source = source_layout.join("blobs").join("sha256");
349    validate_plain_directory(&source, "recorded build sha256 blobs")?;
350    for entry in std::fs::read_dir(&source).map_err(|error| {
351        BoxError::BuildError(format!(
352            "failed to inspect recorded build blobs {}: {error}",
353            source.display()
354        ))
355    })? {
356        let entry = entry.map_err(|error| {
357            BoxError::BuildError(format!("failed to inspect recorded build blob: {error}"))
358        })?;
359        let name = entry.file_name().into_string().map_err(|_| {
360            BoxError::BuildError("recorded build blob name is not UTF-8".to_string())
361        })?;
362        canonical_sha256_digest_hex(&format!("sha256:{name}"))?;
363        let destination = target.join(&name);
364        if destination.exists() {
365            continue;
366        }
367        let mut source_file = open_regular_file_no_follow(&entry.path(), "recorded build blob")?;
368        let mut destination_file = OpenOptions::new()
369            .create_new(true)
370            .write(true)
371            .open(&destination)
372            .map_err(|error| {
373                BoxError::BuildError(format!(
374                    "failed to create assembled blob {}: {error}",
375                    destination.display()
376                ))
377            })?;
378        io::copy(&mut source_file, &mut destination_file).map_err(|error| {
379            BoxError::BuildError(format!(
380                "failed to copy recorded build blob {name}: {error}"
381            ))
382        })?;
383        destination_file.sync_all().map_err(|error| {
384            BoxError::BuildError(format!(
385                "failed to flush assembled build blob {name}: {error}"
386            ))
387        })?;
388    }
389    Ok(())
390}
391
392fn write_new_blob(path: &Path, bytes: &[u8]) -> BoxResult<()> {
393    if path.exists() {
394        return Ok(());
395    }
396    let mut file = OpenOptions::new()
397        .create_new(true)
398        .write(true)
399        .open(path)
400        .map_err(|error| {
401            BoxError::BuildError(format!(
402                "failed to create assembled image-index blob {}: {error}",
403                path.display()
404            ))
405        })?;
406    io::Write::write_all(&mut file, bytes).map_err(|error| {
407        BoxError::BuildError(format!(
408            "failed to write assembled image-index blob {}: {error}",
409            path.display()
410        ))
411    })?;
412    file.sync_all().map_err(|error| {
413        BoxError::BuildError(format!(
414            "failed to flush assembled image-index blob {}: {error}",
415            path.display()
416        ))
417    })
418}
419
420fn parse_digest(hex: &str) -> BoxResult<Sha256Digest> {
421    Sha256Digest::from_str(hex)
422        .map_err(|error| BoxError::BuildError(format!("invalid assembly digest: {error}")))
423}
424
425fn validate_reference(reference: &str) -> Result<(), BuildAssemblyError> {
426    if reference.is_empty()
427        || reference.len() > MAX_REFERENCE_BYTES
428        || reference.trim() != reference
429        || reference.bytes().any(|byte| byte.is_ascii_control())
430    {
431        return Err(BuildAssemblyError::invalid(
432            "destination reference is outside the closed bounds",
433        ));
434    }
435    Ok(())
436}
437
438#[cfg(test)]
439mod tests;