Skip to main content

a3s_box_runtime/oci/build/output/
mod.rs

1//! Single validation and publication authority for native OCI build outputs.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use a3s_box_core::error::{BoxError, Result};
8use a3s_box_core::platform::Platform;
9use a3s_box_core::StoredImage;
10use serde::{Deserialize, Serialize};
11
12use crate::oci::image::canonical_sha256_digest_hex;
13use crate::oci::ImageStore;
14
15mod validation;
16
17use validation::inspect_build_output_layout;
18
19/// OCI media type emitted for a multi-platform image-index root.
20pub const OCI_IMAGE_INDEX_MEDIA_TYPE: &str = "application/vnd.oci.image.index.v1+json";
21/// OCI media type emitted for a single-platform image-manifest root.
22pub const OCI_IMAGE_MANIFEST_MEDIA_TYPE: &str = "application/vnd.oci.image.manifest.v1+json";
23
24const MAX_MULTI_PLATFORM_OUTPUTS: usize = 8;
25
26/// Result of a successful single-platform build.
27#[derive(Debug)]
28pub struct BuildResult {
29    /// Image reference stored in the image store.
30    pub reference: String,
31    /// Content digest.
32    pub digest: String,
33    /// Total image size in bytes.
34    pub size: u64,
35    /// Number of layers.
36    pub layer_count: usize,
37    /// Typed root descriptor published by the OCI layout.
38    pub descriptor: BuildOutputDescriptor,
39    /// Exact platform represented by the single-platform output.
40    pub platform: Platform,
41    /// Durable local OCI image-layout directory owned by the image store.
42    pub layout_directory: PathBuf,
43    /// Number of unique content-addressed blobs in the output layout.
44    pub blob_count: usize,
45    /// Canonical digest of the sorted digest-and-size blob inventory.
46    pub blob_inventory_digest: String,
47}
48
49impl BuildResult {
50    /// Total bytes occupied by the durable OCI layout.
51    pub const fn content_bytes(&self) -> u64 {
52        self.size
53    }
54}
55
56/// Result of deterministically assembling recorded single-platform outputs.
57#[derive(Debug)]
58pub struct MultiPlatformBuildResult {
59    /// Image reference stored in the one image store.
60    pub reference: String,
61    /// Content digest of the root OCI image index.
62    pub digest: String,
63    /// Total bytes occupied by the durable OCI layout.
64    pub size: u64,
65    /// Exact root OCI image-index descriptor.
66    pub descriptor: BuildOutputDescriptor,
67    /// Canonically sorted platforms represented by the image index.
68    pub platforms: Vec<Platform>,
69    /// Number of platform-specific image manifests.
70    pub manifest_count: usize,
71    /// Durable local OCI image-layout directory owned by the image store.
72    pub layout_directory: PathBuf,
73    /// Number of unique content-addressed blobs in the output layout.
74    pub blob_count: usize,
75    /// Canonical digest of the sorted digest-and-size blob inventory.
76    pub blob_inventory_digest: String,
77}
78
79impl MultiPlatformBuildResult {
80    /// Total bytes occupied by the durable OCI layout.
81    pub const fn content_bytes(&self) -> u64 {
82        self.size
83    }
84}
85
86/// Typed root descriptor for a native Box build output.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase", deny_unknown_fields)]
89pub struct BuildOutputDescriptor {
90    /// OCI descriptor media type.
91    pub media_type: String,
92    /// Canonical lowercase SHA-256 content digest.
93    pub digest: String,
94    /// Exact descriptor payload size.
95    pub size: u64,
96}
97
98#[derive(Debug)]
99struct ValidatedBuildLayout {
100    descriptor: BuildOutputDescriptor,
101    platforms: Vec<Platform>,
102    layer_counts: BTreeMap<String, usize>,
103    content_bytes: u64,
104    blob_count: usize,
105    blob_inventory_digest: String,
106}
107
108#[derive(Debug)]
109struct InspectedStoredBuildOutput {
110    reference: String,
111    layout_directory: PathBuf,
112    layout: ValidatedBuildLayout,
113}
114
115#[derive(Clone)]
116enum BuildOutputExpectation {
117    Single(Platform),
118    Multi(Vec<Platform>),
119}
120
121impl BuildOutputExpectation {
122    fn require(&self, layout: &ValidatedBuildLayout) -> Result<()> {
123        match self {
124            Self::Single(platform) => {
125                if layout.descriptor.media_type != OCI_IMAGE_MANIFEST_MEDIA_TYPE
126                    || layout.platforms.as_slice() != std::slice::from_ref(platform)
127                {
128                    return Err(output_error(
129                        "native single-platform output differs from its requested platform",
130                    ));
131                }
132            }
133            Self::Multi(platforms) => {
134                if layout.descriptor.media_type != OCI_IMAGE_INDEX_MEDIA_TYPE
135                    || layout.platforms != *platforms
136                {
137                    return Err(output_error(
138                        "assembled OCI index differs from its requested platforms",
139                    ));
140                }
141            }
142        }
143        Ok(())
144    }
145}
146
147/// Reconstruct one single-platform result through the same complete validation
148/// used before native publication and during durable replay.
149pub(super) fn inspect_stored_build_output(
150    reference: &str,
151    stored: StoredImage,
152    store_root: &Path,
153) -> Result<BuildResult> {
154    inspect_stored_output(reference, stored, store_root)?.into_single()
155}
156
157/// Publish one engine-produced manifest through the sole ImageStore commit
158/// boundary used by both native builds and deterministic index assembly.
159pub(super) async fn publish_single_build_output(
160    reference: &str,
161    digest: &str,
162    source_dir: &Path,
163    store: &Arc<ImageStore>,
164    platform: &Platform,
165) -> Result<BuildResult> {
166    publish_build_output(
167        reference,
168        digest,
169        source_dir,
170        store,
171        BuildOutputExpectation::Single(platform.clone()),
172    )
173    .await?
174    .into_single()
175}
176
177/// Publish one assembled image index through the same sole ImageStore commit
178/// boundary as single-platform native build output.
179pub(super) async fn publish_multi_platform_build_output(
180    reference: &str,
181    digest: &str,
182    source_dir: &Path,
183    store: &Arc<ImageStore>,
184    platforms: &[Platform],
185) -> Result<MultiPlatformBuildResult> {
186    publish_build_output(
187        reference,
188        digest,
189        source_dir,
190        store,
191        BuildOutputExpectation::Multi(platforms.to_vec()),
192    )
193    .await?
194    .into_multi()
195}
196
197async fn publish_build_output(
198    reference: &str,
199    digest: &str,
200    source_dir: &Path,
201    store: &Arc<ImageStore>,
202    expectation: BuildOutputExpectation,
203) -> Result<InspectedStoredBuildOutput> {
204    let source = source_dir.to_path_buf();
205    let expected_digest = digest.to_string();
206    let preflight_expectation = expectation.clone();
207    tokio::task::spawn_blocking(move || {
208        let layout = inspect_build_output_layout(&source)?;
209        if layout.descriptor.digest != expected_digest {
210            return Err(output_error(
211                "native output digest differs from its validated root descriptor",
212            ));
213        }
214        preflight_expectation.require(&layout)
215    })
216    .await
217    .map_err(|error| output_error(format!("OCI output preflight task failed: {error}")))??;
218
219    let stored = store.put(reference, digest, source_dir).await?;
220    let reference = reference.to_string();
221    let store_root = store.store_dir().to_path_buf();
222    let inspected =
223        tokio::task::spawn_blocking(move || inspect_stored_output(&reference, stored, &store_root))
224            .await
225            .map_err(|error| {
226                output_error(format!("OCI output publication task failed: {error}"))
227            })??;
228    expectation.require(&inspected.layout)?;
229    Ok(inspected)
230}
231
232fn inspect_stored_output(
233    reference: &str,
234    stored: StoredImage,
235    store_root: &Path,
236) -> Result<InspectedStoredBuildOutput> {
237    if stored.reference != reference {
238        return Err(output_error(format!(
239            "ImageStore returned reference {:?} for requested build output {reference:?}",
240            stored.reference
241        )));
242    }
243    canonical_sha256_digest_hex(&stored.digest)?;
244
245    let store_root = store_root.canonicalize().map_err(|error| {
246        output_error(format!(
247            "failed to canonicalize ImageStore root {}: {error}",
248            store_root.display()
249        ))
250    })?;
251    let layout_directory = stored.path.canonicalize().map_err(|error| {
252        output_error(format!(
253            "failed to canonicalize stored OCI build output {}: {error}",
254            stored.path.display()
255        ))
256    })?;
257    if !layout_directory.starts_with(&store_root) {
258        return Err(output_error(format!(
259            "stored OCI build output {} escaped ImageStore {}",
260            layout_directory.display(),
261            store_root.display()
262        )));
263    }
264
265    let layout = inspect_build_output_layout(&layout_directory)?;
266    if stored.digest != layout.descriptor.digest {
267        return Err(output_error(
268            "ImageStore digest differs from the validated root descriptor",
269        ));
270    }
271    if stored.size_bytes != layout.content_bytes {
272        return Err(output_error(format!(
273            "ImageStore reports {} bytes but the validated OCI output contains {}",
274            stored.size_bytes, layout.content_bytes
275        )));
276    }
277    Ok(InspectedStoredBuildOutput {
278        reference: reference.to_string(),
279        layout_directory,
280        layout,
281    })
282}
283
284impl InspectedStoredBuildOutput {
285    fn into_single(self) -> Result<BuildResult> {
286        if self.layout.descriptor.media_type != OCI_IMAGE_MANIFEST_MEDIA_TYPE
287            || self.layout.platforms.len() != 1
288        {
289            return Err(output_error(
290                "native single-platform output must contain exactly one image manifest",
291            ));
292        }
293        let platform =
294            self.layout.platforms.first().cloned().ok_or_else(|| {
295                output_error("native single-platform output omitted its platform")
296            })?;
297        let layer_count = self
298            .layout
299            .layer_counts
300            .get(&platform.to_string())
301            .copied()
302            .ok_or_else(|| output_error("native single-platform output omitted its layers"))?;
303        Ok(BuildResult {
304            reference: self.reference,
305            digest: self.layout.descriptor.digest.clone(),
306            size: self.layout.content_bytes,
307            layer_count,
308            descriptor: self.layout.descriptor,
309            platform,
310            layout_directory: self.layout_directory,
311            blob_count: self.layout.blob_count,
312            blob_inventory_digest: self.layout.blob_inventory_digest,
313        })
314    }
315
316    fn into_multi(self) -> Result<MultiPlatformBuildResult> {
317        if self.layout.descriptor.media_type != OCI_IMAGE_INDEX_MEDIA_TYPE
318            || !(2..=MAX_MULTI_PLATFORM_OUTPUTS).contains(&self.layout.platforms.len())
319        {
320            return Err(output_error(
321                "multi-platform output must contain one bounded OCI image index",
322            ));
323        }
324        Ok(MultiPlatformBuildResult {
325            reference: self.reference,
326            digest: self.layout.descriptor.digest.clone(),
327            size: self.layout.content_bytes,
328            descriptor: self.layout.descriptor,
329            manifest_count: self.layout.platforms.len(),
330            platforms: self.layout.platforms,
331            layout_directory: self.layout_directory,
332            blob_count: self.layout.blob_count,
333            blob_inventory_digest: self.layout.blob_inventory_digest,
334        })
335    }
336}
337
338fn output_error(message: impl Into<String>) -> BoxError {
339    BoxError::BuildError(message.into())
340}