1use a3s_box_core::platform::Platform;
11use a3s_box_core::OperationId;
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use thiserror::Error;
16
17use super::cache::{
18 inspect_build_cache_artifact, BuildCacheExportIdentity, BuildCacheReceipt, RecordedBuildCache,
19};
20use super::output::inspect_stored_build_output;
21use super::{BuildCachePolicy, BuildOutputDescriptor, BuildResult, OCI_IMAGE_MANIFEST_MEDIA_TYPE};
22use crate::oci::image::canonical_sha256_digest_hex;
23use crate::oci::ImageStore;
24
25const MAX_OPERATION_ID_BYTES: usize = 255;
26const MAX_RECEIPT_BYTES: u64 = 64 * 1024;
27const MAX_TERMINAL_MESSAGE_BYTES: usize = 4 * 1024;
28const RECEIPT_DIRECTORY: &str = "build-receipts";
29
30mod journal;
31
32pub(super) use journal::{BuildExecutionLease, BuildOperationJournal, LockedBuildOperation};
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct BuildOperationIdentity {
37 operation_id: OperationId,
38 source_digest: String,
39 output_reference: String,
40}
41
42impl BuildOperationIdentity {
43 pub fn new(
45 operation_id: OperationId,
46 source_digest: impl Into<String>,
47 ) -> Result<Self, BuildReceiptError> {
48 if operation_id.as_str().len() > MAX_OPERATION_ID_BYTES
49 || operation_id
50 .as_str()
51 .bytes()
52 .any(|byte| byte.is_ascii_control())
53 {
54 return Err(BuildReceiptError::InvalidIdentity {
55 field: "operation_id",
56 reason: "must contain at most 255 non-control UTF-8 bytes",
57 });
58 }
59 let source_digest = source_digest.into();
60 if canonical_sha256_digest_hex(&source_digest).is_err() {
61 return Err(BuildReceiptError::InvalidIdentity {
62 field: "source_digest",
63 reason: "must be canonical sha256:<64 lowercase hex>",
64 });
65 }
66 let operation_key = operation_key(&operation_id);
67 Ok(Self {
68 operation_id,
69 source_digest,
70 output_reference: format!("a3s-box/build-operation:{operation_key}"),
71 })
72 }
73
74 pub fn operation_id(&self) -> &OperationId {
76 &self.operation_id
77 }
78
79 pub fn source_digest(&self) -> &str {
81 &self.source_digest
82 }
83
84 pub fn output_reference(&self) -> &str {
86 &self.output_reference
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase", deny_unknown_fields)]
97pub(super) struct PendingBuildOperation {
98 schema: String,
99 operation_id: OperationId,
100 source_digest: String,
101 plan_digest: String,
102 output_reference: String,
103}
104
105impl PendingBuildOperation {
106 const SCHEMA: &'static str = "a3s.box.build-output-intent.v1";
107
108 #[cfg_attr(not(test), allow(dead_code))]
109 pub(super) fn new(
110 identity: &BuildOperationIdentity,
111 plan_digest: String,
112 ) -> Result<Self, BuildReceiptError> {
113 let pending = Self {
114 schema: Self::SCHEMA.to_string(),
115 operation_id: identity.operation_id.clone(),
116 source_digest: identity.source_digest.clone(),
117 plan_digest,
118 output_reference: identity.output_reference.clone(),
119 };
120 pending.require_identity(identity, &pending.plan_digest)?;
121 Ok(pending)
122 }
123
124 fn validate(&self) -> Result<(), BuildReceiptError> {
125 if self.schema != Self::SCHEMA
126 || !valid_operation_id(&self.operation_id)
127 || canonical_sha256_digest_hex(&self.source_digest).is_err()
128 || canonical_sha256_digest_hex(&self.plan_digest).is_err()
129 || self.output_reference
130 != format!(
131 "a3s-box/build-operation:{}",
132 operation_key(&self.operation_id)
133 )
134 {
135 return Err(BuildReceiptError::InvalidReceipt {
136 operation_id: self.operation_id.to_string(),
137 message: "pending build intent violates its closed identity".to_string(),
138 });
139 }
140 Ok(())
141 }
142
143 pub(super) fn require_identity(
144 &self,
145 identity: &BuildOperationIdentity,
146 plan_digest: &str,
147 ) -> Result<(), BuildReceiptError> {
148 self.validate()?;
149 if self.operation_id != identity.operation_id
150 || self.source_digest != identity.source_digest
151 || self.plan_digest != plan_digest
152 || self.output_reference != identity.output_reference
153 {
154 return Err(BuildReceiptError::Conflict {
155 operation_id: identity.operation_id.to_string(),
156 message: "the pending source, plan, or output identity differs".to_string(),
157 });
158 }
159 Ok(())
160 }
161
162 fn matches_receipt(&self, receipt: &BuildOutputReceipt) -> bool {
163 self.operation_id == receipt.operation_id
164 && self.source_digest == receipt.source_digest
165 && self.plan_digest == receipt.plan_digest
166 && self.output_reference == receipt.output.reference
167 && receipt.schema == BuildOutputReceipt::LEGACY_SCHEMA
168 && receipt.cache.is_none()
169 }
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase", deny_unknown_fields)]
175pub(super) struct BuildProcessIdentity {
176 pub(super) pid: u32,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub(super) start_time: Option<u64>,
179}
180
181impl BuildProcessIdentity {
182 pub(super) fn current() -> Self {
183 let pid = std::process::id();
184 Self {
185 pid,
186 start_time: crate::process::pid_start_time(pid),
187 }
188 }
189
190 fn validate(self, operation_id: &OperationId) -> Result<(), BuildReceiptError> {
191 if self.pid == 0 {
192 return Err(BuildReceiptError::InvalidReceipt {
193 operation_id: operation_id.to_string(),
194 message: "persisted build process has PID zero".to_string(),
195 });
196 }
197 Ok(())
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204pub(super) enum PersistedBuildPhase {
205 Running,
206 Cancelling,
207 Cancelled,
208 Failed,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(rename_all = "camelCase", deny_unknown_fields)]
214pub(super) struct SupervisedBuildOperation {
215 schema: String,
216 operation_id: OperationId,
217 source_digest: String,
218 plan_digest: String,
219 output_reference: String,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 cache_policy: Option<BuildCachePolicy>,
222 pub(super) phase: PersistedBuildPhase,
223 owner: BuildProcessIdentity,
224 #[serde(skip_serializing_if = "Option::is_none")]
225 pub(super) run_process: Option<BuildProcessIdentity>,
226 #[serde(skip_serializing_if = "Option::is_none")]
227 message: Option<String>,
228 started_at: DateTime<Utc>,
229 updated_at: DateTime<Utc>,
230}
231
232impl SupervisedBuildOperation {
233 pub(super) const SCHEMA: &'static str = "a3s.box.build-operation.v2";
234 const LEGACY_SCHEMA: &'static str = "a3s.box.build-operation.v1";
235
236 pub(super) fn new(
237 identity: &BuildOperationIdentity,
238 plan_digest: String,
239 cache_policy: BuildCachePolicy,
240 ) -> Result<Self, BuildReceiptError> {
241 let now = Utc::now();
242 let operation = Self {
243 schema: Self::SCHEMA.to_string(),
244 operation_id: identity.operation_id.clone(),
245 source_digest: identity.source_digest.clone(),
246 plan_digest,
247 output_reference: identity.output_reference.clone(),
248 cache_policy: Some(cache_policy),
249 phase: PersistedBuildPhase::Running,
250 owner: BuildProcessIdentity::current(),
251 run_process: None,
252 message: None,
253 started_at: now,
254 updated_at: now,
255 };
256 operation.validate()?;
257 operation.require_identity(identity, &operation.plan_digest, cache_policy)?;
258 Ok(operation)
259 }
260
261 pub(super) fn from_pending(
262 pending: &PendingBuildOperation,
263 identity: &BuildOperationIdentity,
264 plan_digest: &str,
265 cache_policy: BuildCachePolicy,
266 ) -> Result<Self, BuildReceiptError> {
267 pending.require_identity(identity, plan_digest)?;
268 Self::new(identity, plan_digest.to_string(), cache_policy)
269 }
270
271 fn validate(&self) -> Result<(), BuildReceiptError> {
272 let schema_is_valid = (self.schema == Self::SCHEMA && self.cache_policy.is_some())
273 || (self.schema == Self::LEGACY_SCHEMA && self.cache_policy.is_none());
274 if !schema_is_valid
275 || !valid_operation_id(&self.operation_id)
276 || canonical_sha256_digest_hex(&self.source_digest).is_err()
277 || canonical_sha256_digest_hex(&self.plan_digest).is_err()
278 || self.output_reference
279 != format!(
280 "a3s-box/build-operation:{}",
281 operation_key(&self.operation_id)
282 )
283 || self.updated_at < self.started_at
284 {
285 return Err(BuildReceiptError::InvalidReceipt {
286 operation_id: self.operation_id.to_string(),
287 message: "supervised build operation violates its closed identity".to_string(),
288 });
289 }
290 self.owner.validate(&self.operation_id)?;
291 if let Some(process) = self.run_process {
292 process.validate(&self.operation_id)?;
293 }
294 let terminal = matches!(
295 self.phase,
296 PersistedBuildPhase::Cancelled | PersistedBuildPhase::Failed
297 );
298 if terminal != self.message.is_some()
299 || self.message.as_ref().is_some_and(|message| {
300 message.is_empty() || message.len() > MAX_TERMINAL_MESSAGE_BYTES
301 })
302 || (terminal && self.run_process.is_some())
303 {
304 return Err(BuildReceiptError::InvalidReceipt {
305 operation_id: self.operation_id.to_string(),
306 message: "supervised build phase fields are inconsistent".to_string(),
307 });
308 }
309 Ok(())
310 }
311
312 pub(super) fn require_identity(
313 &self,
314 identity: &BuildOperationIdentity,
315 plan_digest: &str,
316 cache_policy: BuildCachePolicy,
317 ) -> Result<(), BuildReceiptError> {
318 self.validate()?;
319 if self.operation_id != identity.operation_id
320 || self.source_digest != identity.source_digest
321 || self.plan_digest != plan_digest
322 || self.output_reference != identity.output_reference
323 || self
324 .cache_policy
325 .is_some_and(|persisted| persisted != cache_policy)
326 {
327 return Err(BuildReceiptError::Conflict {
328 operation_id: identity.operation_id.to_string(),
329 message: "the supervised source, plan, or output identity differs".to_string(),
330 });
331 }
332 Ok(())
333 }
334
335 pub(super) const fn cache_policy(&self) -> Option<BuildCachePolicy> {
336 self.cache_policy
337 }
338
339 pub(super) fn request_cancellation(&mut self) -> bool {
340 if self.phase != PersistedBuildPhase::Running {
341 return false;
342 }
343 self.phase = PersistedBuildPhase::Cancelling;
344 self.updated_at = Utc::now();
345 true
346 }
347
348 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
349 pub(super) fn set_run_process(
350 &mut self,
351 process: Option<BuildProcessIdentity>,
352 ) -> Result<(), BuildReceiptError> {
353 if matches!(
354 self.phase,
355 PersistedBuildPhase::Cancelled | PersistedBuildPhase::Failed
356 ) {
357 return Err(BuildReceiptError::Conflict {
358 operation_id: self.operation_id.to_string(),
359 message: "a terminal build cannot own a RUN process".to_string(),
360 });
361 }
362 self.run_process = process;
363 self.updated_at = Utc::now();
364 self.validate()
365 }
366
367 pub(super) fn finish(&mut self, phase: PersistedBuildPhase, message: String) {
368 debug_assert!(matches!(
369 phase,
370 PersistedBuildPhase::Cancelled | PersistedBuildPhase::Failed
371 ));
372 self.phase = phase;
373 self.run_process = None;
374 self.message = Some(bounded_terminal_message(message));
375 self.updated_at = Utc::now();
376 }
377
378 pub(super) fn terminal_message(&self) -> Option<&str> {
379 self.message.as_deref()
380 }
381
382 pub(super) fn operation_id(&self) -> &OperationId {
383 &self.operation_id
384 }
385
386 fn matches_receipt(&self, receipt: &BuildOutputReceipt) -> bool {
387 self.operation_id == receipt.operation_id
388 && self.source_digest == receipt.source_digest
389 && self.plan_digest == receipt.plan_digest
390 && self.output_reference == receipt.output.reference
391 && match self.cache_policy {
392 Some(policy) => {
393 receipt.schema == BuildOutputReceipt::SCHEMA
394 && receipt.matches_cache_policy(policy)
395 }
396 None => {
397 receipt.schema == BuildOutputReceipt::LEGACY_SCHEMA && receipt.cache.is_none()
398 }
399 }
400 }
401}
402
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(untagged)]
406pub(super) enum PersistedBuildOperation {
407 Supervised(SupervisedBuildOperation),
408 Pending(PendingBuildOperation),
409 Succeeded(Box<BuildOutputReceipt>),
410}
411
412impl PersistedBuildOperation {
413 fn validate(&self) -> Result<(), BuildReceiptError> {
414 match self {
415 Self::Supervised(operation) => operation.validate(),
416 Self::Pending(pending) => pending.validate(),
417 Self::Succeeded(receipt) => receipt.validate(),
418 }
419 }
420
421 fn operation_id(&self) -> &OperationId {
422 match self {
423 Self::Supervised(operation) => &operation.operation_id,
424 Self::Pending(pending) => &pending.operation_id,
425 Self::Succeeded(receipt) => &receipt.operation_id,
426 }
427 }
428}
429
430#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
432#[serde(rename_all = "camelCase", deny_unknown_fields)]
433pub struct BuildReceiptOutput {
434 pub reference: String,
436 pub descriptor: BuildOutputDescriptor,
438 pub platform: Platform,
440 pub content_bytes: u64,
442 pub layer_count: u64,
444 pub blob_count: u64,
446 pub blob_inventory_digest: String,
448}
449
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(rename_all = "camelCase", deny_unknown_fields)]
453pub struct BuildOutputReceipt {
454 pub schema: String,
456 pub operation_id: OperationId,
458 pub source_digest: String,
460 pub plan_digest: String,
462 pub output: BuildReceiptOutput,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub cache: Option<BuildCacheReceipt>,
467}
468
469impl BuildOutputReceipt {
470 pub const SCHEMA: &'static str = "a3s.box.build-output-receipt.v2";
472 const LEGACY_SCHEMA: &'static str = "a3s.box.build-output-receipt.v1";
473
474 pub(super) fn from_result(
475 identity: &BuildOperationIdentity,
476 plan_digest: String,
477 result: &BuildResult,
478 cache_policy: BuildCachePolicy,
479 cache: Option<&RecordedBuildCache>,
480 ) -> Result<Self, BuildReceiptError> {
481 let layer_count =
482 u64::try_from(result.layer_count).map_err(|_| BuildReceiptError::OutputInvalid {
483 operation_id: identity.operation_id.to_string(),
484 message: "layer count exceeds the durable receipt range".to_string(),
485 })?;
486 let blob_count =
487 u64::try_from(result.blob_count).map_err(|_| BuildReceiptError::OutputInvalid {
488 operation_id: identity.operation_id.to_string(),
489 message: "blob count exceeds the durable receipt range".to_string(),
490 })?;
491 let receipt = Self {
492 schema: Self::SCHEMA.to_string(),
493 operation_id: identity.operation_id.clone(),
494 source_digest: identity.source_digest.clone(),
495 plan_digest,
496 output: BuildReceiptOutput {
497 reference: result.reference.clone(),
498 descriptor: result.descriptor.clone(),
499 platform: result.platform.clone(),
500 content_bytes: result.content_bytes(),
501 layer_count,
502 blob_count,
503 blob_inventory_digest: result.blob_inventory_digest.clone(),
504 },
505 cache: cache.map(|cache| cache.receipt.clone()),
506 };
507 receipt.validate()?;
508 receipt.require_identity(identity, &receipt.plan_digest, cache_policy)?;
509 Ok(receipt)
510 }
511
512 pub(super) fn from_legacy_result(
513 identity: &BuildOperationIdentity,
514 plan_digest: String,
515 result: &BuildResult,
516 ) -> Result<Self, BuildReceiptError> {
517 let mut receipt = Self::from_result(
518 identity,
519 plan_digest,
520 result,
521 BuildCachePolicy::Disabled,
522 None,
523 )?;
524 receipt.schema = Self::LEGACY_SCHEMA.to_string();
525 receipt.validate()?;
526 Ok(receipt)
527 }
528
529 fn validate(&self) -> Result<(), BuildReceiptError> {
530 let operation_id = self.operation_id.to_string();
531 if self.schema != Self::SCHEMA && self.schema != Self::LEGACY_SCHEMA {
532 return Err(BuildReceiptError::InvalidReceipt {
533 operation_id,
534 message: format!("unsupported schema {:?}", self.schema),
535 });
536 }
537 if self.schema == Self::LEGACY_SCHEMA && self.cache.is_some() {
538 return Err(BuildReceiptError::InvalidReceipt {
539 operation_id,
540 message: "legacy output receipt cannot contain cache evidence".to_string(),
541 });
542 }
543 if !valid_operation_id(&self.operation_id) {
544 return Err(BuildReceiptError::InvalidReceipt {
545 operation_id,
546 message: "operation ID is outside the closed receipt bounds".to_string(),
547 });
548 }
549 for (field, digest) in [
550 ("source digest", self.source_digest.as_str()),
551 ("plan digest", self.plan_digest.as_str()),
552 ("output digest", self.output.descriptor.digest.as_str()),
553 (
554 "blob inventory digest",
555 self.output.blob_inventory_digest.as_str(),
556 ),
557 ] {
558 if canonical_sha256_digest_hex(digest).is_err() {
559 return Err(BuildReceiptError::InvalidReceipt {
560 operation_id,
561 message: format!("{field} is not canonical SHA-256"),
562 });
563 }
564 }
565 if self.output.reference
566 != format!(
567 "a3s-box/build-operation:{}",
568 operation_key(&self.operation_id)
569 )
570 {
571 return Err(BuildReceiptError::InvalidReceipt {
572 operation_id,
573 message: "output reference is not derived from the operation identity".to_string(),
574 });
575 }
576 if self.output.descriptor.media_type != OCI_IMAGE_MANIFEST_MEDIA_TYPE
577 || self.output.descriptor.size == 0
578 || self.output.content_bytes < self.output.descriptor.size
579 || self.output.blob_count < 2
580 {
581 return Err(BuildReceiptError::InvalidReceipt {
582 operation_id,
583 message: "output descriptor or content counts are invalid".to_string(),
584 });
585 }
586 if self.output.platform.os != "linux" || self.output.platform.architecture.trim().is_empty()
587 {
588 return Err(BuildReceiptError::InvalidReceipt {
589 operation_id,
590 message: "output platform is outside the native build contract".to_string(),
591 });
592 }
593 if let Some(cache) = &self.cache {
594 cache
595 .validate()
596 .map_err(|error| BuildReceiptError::CacheInvalid {
597 operation_id: self.operation_id.to_string(),
598 message: error.to_string(),
599 })?;
600 if cache.source_digest != self.source_digest
601 || cache.plan_digest != self.plan_digest
602 || cache.platform != self.output.platform
603 {
604 return Err(BuildReceiptError::InvalidReceipt {
605 operation_id: self.operation_id.to_string(),
606 message: "cache and image receipts have different immutable intent".to_string(),
607 });
608 }
609 }
610 Ok(())
611 }
612
613 pub(super) fn require_identity(
614 &self,
615 identity: &BuildOperationIdentity,
616 plan_digest: &str,
617 cache_policy: BuildCachePolicy,
618 ) -> Result<(), BuildReceiptError> {
619 self.validate()?;
620 if self.operation_id != identity.operation_id
621 || self.source_digest != identity.source_digest
622 || self.plan_digest != plan_digest
623 || self.output.reference != identity.output_reference
624 {
625 return Err(BuildReceiptError::Conflict {
626 operation_id: identity.operation_id.to_string(),
627 message: "the persisted source, plan, or output identity differs".to_string(),
628 });
629 }
630 if self.schema == Self::SCHEMA && !self.matches_cache_policy(cache_policy) {
631 return Err(BuildReceiptError::InvalidReceipt {
632 operation_id: identity.operation_id.to_string(),
633 message: "cache evidence differs from the admitted build-plan policy".to_string(),
634 });
635 }
636 Ok(())
637 }
638
639 fn matches_cache_policy(&self, cache_policy: BuildCachePolicy) -> bool {
640 match cache_policy {
641 BuildCachePolicy::ContentAddressed => self.cache.is_some(),
642 BuildCachePolicy::Disabled => self.cache.is_none(),
643 }
644 }
645
646 pub(super) async fn resolve(
647 &self,
648 store: &ImageStore,
649 ) -> Result<BuildResult, BuildReceiptError> {
650 self.validate()?;
651 let actual = inspect_stored_output(&self.operation_id, &self.output.reference, store)
652 .await?
653 .ok_or_else(|| BuildReceiptError::OutputMissing {
654 operation_id: self.operation_id.to_string(),
655 reference: self.output.reference.clone(),
656 })?;
657 if actual.descriptor != self.output.descriptor
658 || actual.platform != self.output.platform
659 || actual.content_bytes() != self.output.content_bytes
660 || u64::try_from(actual.layer_count).ok() != Some(self.output.layer_count)
661 || u64::try_from(actual.blob_count).ok() != Some(self.output.blob_count)
662 || actual.blob_inventory_digest != self.output.blob_inventory_digest
663 {
664 return Err(BuildReceiptError::OutputInvalid {
665 operation_id: self.operation_id.to_string(),
666 message: "revalidated ImageStore output differs from the receipt".to_string(),
667 });
668 }
669 Ok(actual)
670 }
671
672 pub(super) async fn resolve_cache(
673 &self,
674 layout_directory: &std::path::Path,
675 ) -> Result<Option<RecordedBuildCache>, BuildReceiptError> {
676 let Some(expected) = self.cache.clone() else {
677 return Ok(None);
678 };
679 let identity = BuildCacheExportIdentity::new(
680 self.source_digest.clone(),
681 self.plan_digest.clone(),
682 expected.platform.clone(),
683 )
684 .map_err(|error| BuildReceiptError::CacheInvalid {
685 operation_id: self.operation_id.to_string(),
686 message: error.to_string(),
687 })?;
688 let root = layout_directory.to_path_buf();
689 let operation = self.operation_id.to_string();
690 tokio::task::spawn_blocking(move || {
691 inspect_build_cache_artifact(&root, &identity, Some(&expected))
692 })
693 .await
694 .map_err(|error| BuildReceiptError::Task {
695 operation_id: self.operation_id.to_string(),
696 message: format!("cache receipt validation task failed: {error}"),
697 })?
698 .map(Some)
699 .map_err(|error| BuildReceiptError::CacheInvalid {
700 operation_id: operation,
701 message: error.to_string(),
702 })
703 }
704}
705
706pub(super) async fn inspect_stored_output(
707 operation_id: &OperationId,
708 reference: &str,
709 store: &ImageStore,
710) -> Result<Option<BuildResult>, BuildReceiptError> {
711 let Some(stored) =
712 store
713 .get_checked(reference)
714 .await
715 .map_err(|error| BuildReceiptError::OutputInvalid {
716 operation_id: operation_id.to_string(),
717 message: format!("failed to read the authoritative ImageStore index: {error}"),
718 })?
719 else {
720 return Ok(None);
721 };
722 let reference = reference.to_string();
723 let store_root = store.store_dir().to_path_buf();
724 let output = tokio::task::spawn_blocking(move || {
725 inspect_stored_build_output(&reference, stored, &store_root)
726 })
727 .await
728 .map_err(|error| BuildReceiptError::Task {
729 operation_id: operation_id.to_string(),
730 message: format!("OCI receipt validation task failed: {error}"),
731 })?
732 .map_err(|error| BuildReceiptError::OutputInvalid {
733 operation_id: operation_id.to_string(),
734 message: error.to_string(),
735 })?;
736 Ok(Some(output))
737}
738
739#[derive(Debug)]
741pub struct RecordedBuildResult {
742 pub receipt: BuildOutputReceipt,
744 pub output: BuildResult,
746 pub cache: Option<RecordedBuildCache>,
748 pub replayed: bool,
750}
751
752#[derive(Debug)]
754pub enum RecordedBuildStatus {
755 Running,
757 Cancelling,
759 Cancelled { message: String },
761 Failed { message: String },
763 Succeeded(Box<RecordedBuildResult>),
765}
766
767#[derive(Debug, Clone, Copy, PartialEq, Eq)]
769pub enum BuildCancellationOutcome {
770 NotFound,
772 Requested,
774 AlreadyRequested,
776 AlreadyCancelled,
778 AlreadyTerminal,
780}
781
782#[derive(Debug, Error)]
784pub enum BuildReceiptError {
785 #[error("Box build receipt identity field {field} {reason}")]
787 InvalidIdentity {
788 field: &'static str,
789 reason: &'static str,
790 },
791 #[error("Box build receipt store is unsafe: {message}")]
793 UnsafeStore { message: String },
794 #[error("Box build receipt store I/O failed: {message}: {source}")]
796 StoreIo {
797 message: String,
798 #[source]
799 source: std::io::Error,
800 },
801 #[error("Box build receipt task failed for {operation_id}: {message}")]
803 Task {
804 operation_id: String,
805 message: String,
806 },
807 #[error("Box build receipt is invalid for {operation_id}: {message}")]
809 InvalidReceipt {
810 operation_id: String,
811 message: String,
812 },
813 #[error("Box build receipt conflict for {operation_id}: {message}")]
815 Conflict {
816 operation_id: String,
817 message: String,
818 },
819 #[error("Box build output is missing for {operation_id}: ImageStore reference {reference}")]
821 OutputMissing {
822 operation_id: String,
823 reference: String,
824 },
825 #[error("Box build output is invalid for {operation_id}: {message}")]
827 OutputInvalid {
828 operation_id: String,
829 message: String,
830 },
831 #[error("Box build cache is invalid for {operation_id}: {message}")]
833 CacheInvalid {
834 operation_id: String,
835 message: String,
836 },
837}
838
839fn operation_key(operation_id: &OperationId) -> String {
840 format!("{:x}", Sha256::digest(operation_id.as_str().as_bytes()))
841}
842
843fn valid_operation_id(operation_id: &OperationId) -> bool {
844 operation_id.as_str().len() <= MAX_OPERATION_ID_BYTES
845 && !operation_id
846 .as_str()
847 .bytes()
848 .any(|byte| byte.is_ascii_control())
849}
850
851fn bounded_terminal_message(mut message: String) -> String {
852 if message.is_empty() {
853 return "build operation ended without an error message".to_string();
854 }
855 if message.len() <= MAX_TERMINAL_MESSAGE_BYTES {
856 return message;
857 }
858 let mut boundary = MAX_TERMINAL_MESSAGE_BYTES;
859 while !message.is_char_boundary(boundary) {
860 boundary -= 1;
861 }
862 message.truncate(boundary);
863 message
864}
865
866#[cfg(test)]
867mod tests;