a3s_box_runtime/oci/build/output/
mod.rs1use 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
19pub const OCI_IMAGE_INDEX_MEDIA_TYPE: &str = "application/vnd.oci.image.index.v1+json";
21pub const OCI_IMAGE_MANIFEST_MEDIA_TYPE: &str = "application/vnd.oci.image.manifest.v1+json";
23
24const MAX_MULTI_PLATFORM_OUTPUTS: usize = 8;
25
26#[derive(Debug)]
28pub struct BuildResult {
29 pub reference: String,
31 pub digest: String,
33 pub size: u64,
35 pub layer_count: usize,
37 pub descriptor: BuildOutputDescriptor,
39 pub platform: Platform,
41 pub layout_directory: PathBuf,
43 pub blob_count: usize,
45 pub blob_inventory_digest: String,
47}
48
49impl BuildResult {
50 pub const fn content_bytes(&self) -> u64 {
52 self.size
53 }
54}
55
56#[derive(Debug)]
58pub struct MultiPlatformBuildResult {
59 pub reference: String,
61 pub digest: String,
63 pub size: u64,
65 pub descriptor: BuildOutputDescriptor,
67 pub platforms: Vec<Platform>,
69 pub manifest_count: usize,
71 pub layout_directory: PathBuf,
73 pub blob_count: usize,
75 pub blob_inventory_digest: String,
77}
78
79impl MultiPlatformBuildResult {
80 pub const fn content_bytes(&self) -> u64 {
82 self.size
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase", deny_unknown_fields)]
89pub struct BuildOutputDescriptor {
90 pub media_type: String,
92 pub digest: String,
94 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
147pub(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
157pub(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
177pub(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}