a3s_box_runtime/oci/build/
assembly.rs1use 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#[derive(Debug, Clone)]
33pub struct BuildOutputAssemblyInput {
34 plan: BoxBuildPlan,
35 receipt: BuildOutputReceipt,
36}
37
38impl BuildOutputAssemblyInput {
39 pub const fn new(plan: BoxBuildPlan, receipt: BuildOutputReceipt) -> Self {
41 Self { plan, receipt }
42 }
43
44 pub const fn plan(&self) -> &BoxBuildPlan {
46 &self.plan
47 }
48
49 pub const fn receipt(&self) -> &BuildOutputReceipt {
51 &self.receipt
52 }
53}
54
55#[derive(Debug, Clone)]
61pub struct BuildOutputAssembly {
62 reference: String,
63 source_digest: String,
64 inputs: Vec<BuildOutputAssemblyInput>,
65}
66
67impl BuildOutputAssembly {
68 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 pub fn reference(&self) -> &str {
143 &self.reference
144 }
145
146 pub fn source_digest(&self) -> &str {
148 &self.source_digest
149 }
150
151 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#[derive(Debug, Error)]
166pub enum BuildAssemblyError {
167 #[error("Box build output assembly is invalid: {message}")]
169 Invalid { message: String },
170 #[error(transparent)]
172 Plan(#[from] BoxBuildPlanError),
173 #[error(transparent)]
175 Receipt(#[from] BuildReceiptError),
176 #[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
189pub 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 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;