1mod persistence;
8#[cfg(test)]
9mod tests;
10
11use crate::{
12 component_topology::PlannedFleetSubnetRootTopology,
13 release_set::{
14 GZIP_MAGIC, WASM_MAGIC,
15 fixture::{FixtureArtifactError, FixtureArtifactManifest},
16 valid_package_name, validate_release_artifact_relative_path,
17 },
18};
19use std::{
20 collections::{BTreeMap, BTreeSet},
21 io::Read,
22};
23
24use canic_core::{
25 bootstrap::compiled::{ComponentTopology, ComponentTopologyError},
26 cdk::utils::hash::{decode_hex, sha256_hex},
27 ids::{
28 CanisterRole, ComponentSpecId, ComponentTopologyDigest, FleetSubnetRootBinding,
29 ReleaseBuildId, ReleaseSetDigest,
30 },
31 role_contract::ProtocolProfileDigest,
32};
33use flate2::read::GzDecoder;
34use serde::{Deserialize, Serialize};
35use sha2_host::{Digest, Sha256};
36use thiserror::Error as ThisError;
37
38pub(super) use persistence::load_retained_application_artifact_union;
39
40pub use persistence::{
41 ApplicationArtifactFileBuildOutput, ApplicationArtifactUnionPersistenceError,
42 PersistedApplicationArtifactUnion, compile_and_persist_application_artifact_union,
43 load_persisted_application_artifact_union,
44};
45
46const MAX_ARTIFACT_PATH_BYTES: usize = 4_096;
47const SHA_256_HEX_BYTES: usize = 64;
48
49#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct ApplicationArtifactBuildTarget {
57 pub role: CanisterRole,
58 pub package: String,
59 pub wasm_relative_path: String,
60 pub wasm_gz_relative_path: String,
61}
62
63#[derive(Clone, Debug, Eq, PartialEq)]
70pub struct ApplicationArtifactBuildOutput {
71 pub role: CanisterRole,
72 pub package: String,
73 pub release_build_id: ReleaseBuildId,
74 pub wasm_relative_path: String,
75 pub wasm: Vec<u8>,
76 pub wasm_gz_relative_path: String,
77 pub wasm_gz: Vec<u8>,
78 pub candid_sha256: [u8; 32],
79 pub protocol_profile_digest: ProtocolProfileDigest,
80}
81
82#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
89#[serde(deny_unknown_fields)]
90pub struct ApplicationArtifactEntry {
91 pub role: CanisterRole,
92 pub package: String,
93 pub release_build_id: ReleaseBuildId,
94 pub wasm_relative_path: String,
95 pub wasm_size_bytes: u64,
96 pub wasm_sha256_hex: String,
97 pub wasm_gz_relative_path: String,
98 pub wasm_gz_size_bytes: u64,
99 pub wasm_gz_sha256_hex: String,
100 pub candid_sha256: [u8; 32],
101 pub protocol_profile_digest: ProtocolProfileDigest,
102}
103
104#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
111#[serde(deny_unknown_fields)]
112pub struct ApplicationArtifactUnion {
113 pub release_build_id: ReleaseBuildId,
114 pub fleet_component_topology_digest: ComponentTopologyDigest,
115 pub entries: Vec<ApplicationArtifactEntry>,
116}
117
118impl ApplicationArtifactUnion {
119 pub fn compile(
121 topology: &ComponentTopology,
122 release_build_id: ReleaseBuildId,
123 targets: &[ApplicationArtifactBuildTarget],
124 outputs: &[ApplicationArtifactBuildOutput],
125 ) -> Result<Self, ApplicationReleaseSetError> {
126 Self::validate_build_targets(topology, targets)?;
127 let targets = targets
128 .iter()
129 .map(|target| (&target.role, target))
130 .collect::<BTreeMap<_, _>>();
131
132 let mut output_roles = outputs
133 .iter()
134 .map(|output| output.role.clone())
135 .collect::<Vec<_>>();
136 Self::validate_build_output_roles(topology, &mut output_roles)?;
137
138 let mut entries = outputs
139 .iter()
140 .map(|output| {
141 let target = targets.get(&output.role).copied().ok_or_else(|| {
142 ApplicationReleaseSetError::MissingBuildTarget {
143 role: output.role.clone(),
144 }
145 })?;
146 compile_entry(release_build_id, target, output)
147 })
148 .collect::<Result<Vec<_>, _>>()?;
149 entries.sort_unstable_by(|left, right| left.role.cmp(&right.role));
150 let union = Self {
151 release_build_id,
152 fleet_component_topology_digest: topology.digest()?,
153 entries,
154 };
155 union.validate_against(topology)?;
156 Ok(union)
157 }
158
159 fn validate_build_targets(
160 topology: &ComponentTopology,
161 targets: &[ApplicationArtifactBuildTarget],
162 ) -> Result<(), ApplicationReleaseSetError> {
163 let expected_roles = topology_roles(topology);
164 let mut target_roles = targets
165 .iter()
166 .map(|target| target.role.clone())
167 .collect::<Vec<_>>();
168 target_roles.sort();
169 if target_roles != expected_roles {
170 return Err(ApplicationReleaseSetError::BuildTargetRoleSet {
171 expected: expected_roles,
172 actual: target_roles,
173 });
174 }
175 Ok(())
176 }
177
178 fn validate_build_output_roles(
179 topology: &ComponentTopology,
180 actual: &mut Vec<CanisterRole>,
181 ) -> Result<(), ApplicationReleaseSetError> {
182 let expected = topology_roles(topology);
183 actual.sort();
184 if *actual != expected {
185 return Err(ApplicationReleaseSetError::BuildOutputRoleSet {
186 expected,
187 actual: actual.clone(),
188 });
189 }
190 Ok(())
191 }
192
193 pub fn validate_against(
195 &self,
196 topology: &ComponentTopology,
197 ) -> Result<(), ApplicationReleaseSetError> {
198 self.validate_retained_shape()?;
199 let digest = topology.digest()?;
200 if self.fleet_component_topology_digest != digest {
201 return Err(ApplicationReleaseSetError::UnionTopologyDigestMismatch {
202 expected: digest,
203 actual: self.fleet_component_topology_digest,
204 });
205 }
206
207 let expected = topology_roles(topology);
208 let actual = self
209 .entries
210 .iter()
211 .map(|entry| entry.role.clone())
212 .collect::<Vec<_>>();
213 if actual != expected {
214 return Err(ApplicationReleaseSetError::ArtifactUnionRoleSet { expected, actual });
215 }
216 Ok(())
217 }
218
219 pub fn canonical_bytes(
221 &self,
222 topology: &ComponentTopology,
223 ) -> Result<Vec<u8>, ApplicationReleaseSetError> {
224 self.validate_against(topology)?;
225 serde_json::to_vec(self).map_err(ApplicationReleaseSetError::Serialization)
226 }
227
228 pub fn digest(
230 &self,
231 topology: &ComponentTopology,
232 ) -> Result<[u8; 32], ApplicationReleaseSetError> {
233 Ok(Sha256::digest(self.canonical_bytes(topology)?).into())
234 }
235
236 pub(crate) fn validate_retained_shape(&self) -> Result<(), ApplicationReleaseSetError> {
241 if self.entries.is_empty() {
242 return Err(ApplicationReleaseSetError::EmptyArtifactUnion);
243 }
244
245 let mut previous: Option<&CanisterRole> = None;
246 let mut paths = BTreeSet::new();
247 for entry in &self.entries {
248 if let Some(previous) = previous
249 && previous >= &entry.role
250 {
251 return Err(ApplicationReleaseSetError::NonCanonicalArtifactOrder {
252 previous: previous.clone(),
253 current: entry.role.clone(),
254 });
255 }
256 previous = Some(&entry.role);
257 validate_entry(self.release_build_id, entry)?;
258 for path in [&entry.wasm_relative_path, &entry.wasm_gz_relative_path] {
259 if !paths.insert(path.as_str()) {
260 return Err(ApplicationReleaseSetError::DuplicateArtifactPath {
261 path: path.clone(),
262 });
263 }
264 }
265 }
266 Ok(())
267 }
268
269 fn get(&self, role: &CanisterRole) -> Option<&ApplicationArtifactEntry> {
270 self.entries
271 .binary_search_by(|entry| entry.role.cmp(role))
272 .ok()
273 .map(|index| &self.entries[index])
274 }
275}
276
277#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
284#[serde(rename_all = "snake_case")]
285pub enum ApplicationReleaseSetEntryKind {
286 Component,
287 ComponentChild,
288}
289
290#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
297#[serde(deny_unknown_fields)]
298pub struct FleetSubnetRootReleaseSetEntry {
299 pub component_spec: ComponentSpecId,
300 pub kind: ApplicationReleaseSetEntryKind,
301 pub artifact: ApplicationArtifactEntry,
302}
303
304#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
311#[serde(deny_unknown_fields)]
312pub struct FleetSubnetRootReleaseSetManifest {
313 pub release_build_id: ReleaseBuildId,
314 pub component_topology_digest: ComponentTopologyDigest,
315 pub entries: Vec<FleetSubnetRootReleaseSetEntry>,
316 pub fixtures: Vec<canic_core::dto::root_store::RootStoreFixture>,
317}
318
319impl FleetSubnetRootReleaseSetManifest {
320 #[must_use]
322 pub fn root_store_manifest(&self) -> canic_core::dto::root_store::RootStoreReleaseSetManifest {
323 use canic_core::dto::root_store::{
324 RootStoreArtifact, RootStoreReleaseSetEntry, RootStoreReleaseSetEntryKind,
325 RootStoreReleaseSetManifest,
326 };
327
328 RootStoreReleaseSetManifest {
329 fixtures: self.fixtures.clone(),
330 release_build_id: self.release_build_id,
331 component_topology_digest: self.component_topology_digest,
332 entries: self
333 .entries
334 .iter()
335 .map(|entry| RootStoreReleaseSetEntry {
336 component_spec: entry.component_spec.clone(),
337 kind: match entry.kind {
338 ApplicationReleaseSetEntryKind::Component => {
339 RootStoreReleaseSetEntryKind::Component
340 }
341 ApplicationReleaseSetEntryKind::ComponentChild => {
342 RootStoreReleaseSetEntryKind::ComponentChild
343 }
344 },
345 artifact: RootStoreArtifact {
346 role: entry.artifact.role.clone(),
347 package: entry.artifact.package.clone(),
348 release_build_id: entry.artifact.release_build_id,
349 wasm_relative_path: entry.artifact.wasm_relative_path.clone(),
350 wasm_size_bytes: entry.artifact.wasm_size_bytes,
351 wasm_sha256_hex: entry.artifact.wasm_sha256_hex.clone(),
352 wasm_gz_relative_path: entry.artifact.wasm_gz_relative_path.clone(),
353 wasm_gz_size_bytes: entry.artifact.wasm_gz_size_bytes,
354 wasm_gz_sha256_hex: entry.artifact.wasm_gz_sha256_hex.clone(),
355 candid_sha256: entry.artifact.candid_sha256,
356 protocol_profile_digest: entry.artifact.protocol_profile_digest,
357 },
358 })
359 .collect(),
360 }
361 }
362
363 pub fn project(
365 topology: &ComponentTopology,
366 binding: &FleetSubnetRootBinding,
367 union: &ApplicationArtifactUnion,
368 fixtures: &FixtureArtifactManifest,
369 ) -> Result<Self, ApplicationReleaseSetError> {
370 Self::project_for_root(
371 topology,
372 &binding.component_admissions,
373 binding.component_topology_digest,
374 binding.limits.maximum_wasm_store_bytes,
375 union,
376 fixtures,
377 )
378 }
379
380 pub fn project_planned(
382 topology: &ComponentTopology,
383 root: &PlannedFleetSubnetRootTopology,
384 union: &ApplicationArtifactUnion,
385 fixtures: &FixtureArtifactManifest,
386 ) -> Result<Self, ApplicationReleaseSetError> {
387 Self::project_for_root(
388 topology,
389 &root.component_admissions,
390 root.component_topology_digest,
391 root.limits.maximum_wasm_store_bytes,
392 union,
393 fixtures,
394 )
395 }
396
397 fn project_for_root(
398 topology: &ComponentTopology,
399 component_admissions: &[canic_core::ids::ComponentSpecAdmission],
400 component_topology_digest: ComponentTopologyDigest,
401 maximum_wasm_store_bytes: u64,
402 union: &ApplicationArtifactUnion,
403 fixtures: &FixtureArtifactManifest,
404 ) -> Result<Self, ApplicationReleaseSetError> {
405 union.validate_against(topology)?;
406 fixtures.validate(topology, union.release_build_id)?;
407 let projected = topology.project_for_admissions(component_admissions)?;
408 let digest = projected.digest()?;
409 if digest != component_topology_digest {
410 return Err(ApplicationReleaseSetError::RootTopologyDigestMismatch {
411 expected: digest,
412 actual: component_topology_digest,
413 });
414 }
415
416 let mut entries = Vec::new();
417 for component_spec in &projected.component_specs {
418 entries.push(project_entry(
419 &component_spec.component_spec,
420 ApplicationReleaseSetEntryKind::Component,
421 &component_spec.component_role,
422 union,
423 )?);
424 for child in &component_spec.children {
425 entries.push(project_entry(
426 &component_spec.component_spec,
427 ApplicationReleaseSetEntryKind::ComponentChild,
428 &child.role,
429 union,
430 )?);
431 }
432 }
433
434 let manifest = Self {
435 release_build_id: union.release_build_id,
436 component_topology_digest: digest,
437 fixtures: project_fixtures(&projected, fixtures),
438 entries,
439 };
440 manifest.validate_for_root(
441 topology,
442 component_admissions,
443 component_topology_digest,
444 maximum_wasm_store_bytes,
445 union,
446 fixtures,
447 )?;
448 Ok(manifest)
449 }
450
451 pub fn validate_against(
453 &self,
454 topology: &ComponentTopology,
455 binding: &FleetSubnetRootBinding,
456 union: &ApplicationArtifactUnion,
457 fixtures: &FixtureArtifactManifest,
458 ) -> Result<(), ApplicationReleaseSetError> {
459 self.validate_for_root(
460 topology,
461 &binding.component_admissions,
462 binding.component_topology_digest,
463 binding.limits.maximum_wasm_store_bytes,
464 union,
465 fixtures,
466 )
467 }
468
469 pub fn validate_against_planned(
471 &self,
472 topology: &ComponentTopology,
473 root: &PlannedFleetSubnetRootTopology,
474 union: &ApplicationArtifactUnion,
475 fixtures: &FixtureArtifactManifest,
476 ) -> Result<(), ApplicationReleaseSetError> {
477 self.validate_for_root(
478 topology,
479 &root.component_admissions,
480 root.component_topology_digest,
481 root.limits.maximum_wasm_store_bytes,
482 union,
483 fixtures,
484 )
485 }
486
487 fn validate_for_root(
488 &self,
489 topology: &ComponentTopology,
490 component_admissions: &[canic_core::ids::ComponentSpecAdmission],
491 component_topology_digest: ComponentTopologyDigest,
492 maximum_wasm_store_bytes: u64,
493 union: &ApplicationArtifactUnion,
494 fixtures: &FixtureArtifactManifest,
495 ) -> Result<(), ApplicationReleaseSetError> {
496 union.validate_against(topology)?;
497 fixtures.validate(topology, union.release_build_id)?;
498 if self.release_build_id != union.release_build_id {
499 return Err(ApplicationReleaseSetError::ManifestBuildMismatch {
500 expected: union.release_build_id,
501 actual: self.release_build_id,
502 });
503 }
504
505 let projected = topology.project_for_admissions(component_admissions)?;
506 let digest = projected.digest()?;
507 if component_topology_digest != digest {
508 return Err(ApplicationReleaseSetError::RootTopologyDigestMismatch {
509 expected: digest,
510 actual: component_topology_digest,
511 });
512 }
513 if self.component_topology_digest != digest {
514 return Err(ApplicationReleaseSetError::ManifestTopologyDigestMismatch {
515 expected: digest,
516 actual: self.component_topology_digest,
517 });
518 }
519
520 let expected = expected_projection_entries(&projected, union)?;
521 if self.entries != expected || self.fixtures != project_fixtures(&projected, fixtures) {
522 return Err(ApplicationReleaseSetError::ProjectionMismatch);
523 }
524
525 let mut unique_artifacts = BTreeSet::new();
526 let total_bytes = self.entries.iter().try_fold(0_u64, |total, entry| {
527 let artifact = &entry.artifact;
528 if unique_artifacts.insert((
529 artifact.wasm_size_bytes,
530 artifact.wasm_sha256_hex.as_str(),
531 artifact.wasm_gz_size_bytes,
532 artifact.wasm_gz_sha256_hex.as_str(),
533 )) {
534 total
535 .checked_add(artifact.wasm_gz_size_bytes)
536 .ok_or(ApplicationReleaseSetError::StoreBytesOverflow)
537 } else {
538 Ok(total)
539 }
540 })?;
541 let mut unique_sources = BTreeSet::new();
542 let total_bytes = self
543 .fixtures
544 .iter()
545 .try_fold(total_bytes, |total, source| {
546 if unique_sources.insert(source.content_id) {
547 total
548 .checked_add(source.descriptor.encoded_length)
549 .ok_or(ApplicationReleaseSetError::StoreBytesOverflow)
550 } else {
551 Ok(total)
552 }
553 })?;
554 if total_bytes > maximum_wasm_store_bytes {
555 return Err(ApplicationReleaseSetError::WasmStoreLimitExceeded {
556 maximum_bytes: maximum_wasm_store_bytes,
557 required_bytes: total_bytes,
558 });
559 }
560
561 Ok(())
562 }
563
564 pub fn canonical_bytes(
566 &self,
567 topology: &ComponentTopology,
568 binding: &FleetSubnetRootBinding,
569 union: &ApplicationArtifactUnion,
570 fixtures: &FixtureArtifactManifest,
571 ) -> Result<Vec<u8>, ApplicationReleaseSetError> {
572 self.validate_against(topology, binding, union, fixtures)?;
573 serde_json::to_vec(self).map_err(ApplicationReleaseSetError::Serialization)
574 }
575
576 pub fn digest(
578 &self,
579 topology: &ComponentTopology,
580 binding: &FleetSubnetRootBinding,
581 union: &ApplicationArtifactUnion,
582 fixtures: &FixtureArtifactManifest,
583 ) -> Result<ReleaseSetDigest, ApplicationReleaseSetError> {
584 Ok(ReleaseSetDigest::from_bytes(
585 Sha256::digest(self.canonical_bytes(topology, binding, union, fixtures)?).into(),
586 ))
587 }
588
589 pub fn canonical_bytes_planned(
591 &self,
592 topology: &ComponentTopology,
593 root: &PlannedFleetSubnetRootTopology,
594 union: &ApplicationArtifactUnion,
595 fixtures: &FixtureArtifactManifest,
596 ) -> Result<Vec<u8>, ApplicationReleaseSetError> {
597 self.validate_against_planned(topology, root, union, fixtures)?;
598 serde_json::to_vec(self).map_err(ApplicationReleaseSetError::Serialization)
599 }
600
601 pub fn digest_planned(
603 &self,
604 topology: &ComponentTopology,
605 root: &PlannedFleetSubnetRootTopology,
606 union: &ApplicationArtifactUnion,
607 fixtures: &FixtureArtifactManifest,
608 ) -> Result<ReleaseSetDigest, ApplicationReleaseSetError> {
609 Ok(ReleaseSetDigest::from_bytes(
610 Sha256::digest(self.canonical_bytes_planned(topology, root, union, fixtures)?).into(),
611 ))
612 }
613}
614
615#[derive(Debug, ThisError)]
622pub enum ApplicationReleaseSetError {
623 #[error(transparent)]
624 Fixture(#[from] FixtureArtifactError),
625
626 #[error("application artifact {role} {kind} size cannot be represented")]
627 ArtifactSizeOverflow {
628 role: CanisterRole,
629 kind: &'static str,
630 },
631
632 #[error("application artifact union role set differs from Component Topology")]
633 ArtifactUnionRoleSet {
634 expected: Vec<CanisterRole>,
635 actual: Vec<CanisterRole>,
636 },
637
638 #[error("application build output {role} package {actual} does not match target {expected}")]
639 BuildOutputPackageMismatch {
640 role: CanisterRole,
641 expected: String,
642 actual: String,
643 },
644
645 #[error(
646 "application build output {role} {kind} path {actual} does not match target {expected}"
647 )]
648 BuildOutputPathMismatch {
649 role: CanisterRole,
650 kind: &'static str,
651 expected: String,
652 actual: String,
653 },
654
655 #[error("application build output role set differs from Component Topology")]
656 BuildOutputRoleSet {
657 expected: Vec<CanisterRole>,
658 actual: Vec<CanisterRole>,
659 },
660
661 #[error("application build target role set differs from Component Topology")]
662 BuildTargetRoleSet {
663 expected: Vec<CanisterRole>,
664 actual: Vec<CanisterRole>,
665 },
666
667 #[error("application artifact path is duplicated: {path}")]
668 DuplicateArtifactPath { path: String },
669
670 #[error("application artifact union must not be empty")]
671 EmptyArtifactUnion,
672
673 #[error("application artifact {role} has an empty {kind} payload")]
674 EmptyArtifact {
675 role: CanisterRole,
676 kind: &'static str,
677 },
678
679 #[error("application artifact {role} has invalid gzip Wasm: {source}")]
680 InvalidGzip {
681 role: CanisterRole,
682 #[source]
683 source: std::io::Error,
684 },
685
686 #[error("application artifact {role} has an invalid package identity: {package}")]
687 InvalidPackage { role: CanisterRole, package: String },
688
689 #[error("application artifact {role} has an invalid {kind} path: {path}")]
690 InvalidPath {
691 role: CanisterRole,
692 kind: &'static str,
693 path: String,
694 },
695
696 #[error("application artifact {role} has an invalid {kind} SHA-256: {value}")]
697 InvalidSha256 {
698 role: CanisterRole,
699 kind: &'static str,
700 value: String,
701 },
702
703 #[error("application artifact {role} does not begin with the Wasm magic bytes")]
704 InvalidWasm { role: CanisterRole },
705
706 #[error(
707 "application release-set manifest build {actual} does not match artifact union build {expected}"
708 )]
709 ManifestBuildMismatch {
710 expected: ReleaseBuildId,
711 actual: ReleaseBuildId,
712 },
713
714 #[error("application release-set manifest topology digest differs from its root projection")]
715 ManifestTopologyDigestMismatch {
716 expected: ComponentTopologyDigest,
717 actual: ComponentTopologyDigest,
718 },
719
720 #[error("application artifact union has no build target for role {role}")]
721 MissingBuildTarget { role: CanisterRole },
722
723 #[error("application artifact union has no qualified artifact for role {role}")]
724 MissingArtifact { role: CanisterRole },
725
726 #[error("application artifact order is not canonical: {previous} before {current}")]
727 NonCanonicalArtifactOrder {
728 previous: CanisterRole,
729 current: CanisterRole,
730 },
731
732 #[error("application release-set manifest differs from the exact admitted projection")]
733 ProjectionMismatch,
734
735 #[error(
736 "application artifact {role} release build {actual} does not match union build {expected}"
737 )]
738 ReleaseBuildMismatch {
739 role: CanisterRole,
740 expected: ReleaseBuildId,
741 actual: ReleaseBuildId,
742 },
743
744 #[error("application artifact {role} raw and gzip Wasm representations differ")]
745 RepresentationMismatch { role: CanisterRole },
746
747 #[error("Fleet Subnet Root topology digest differs from its admitted projection")]
748 RootTopologyDigestMismatch {
749 expected: ComponentTopologyDigest,
750 actual: ComponentTopologyDigest,
751 },
752
753 #[error("failed to serialize application release-set evidence: {0}")]
754 Serialization(serde_json::Error),
755
756 #[error("root release-set artifact byte total overflowed")]
757 StoreBytesOverflow,
758
759 #[error(transparent)]
760 Topology(#[from] ComponentTopologyError),
761
762 #[error("application artifact union topology digest differs from Component Topology")]
763 UnionTopologyDigestMismatch {
764 expected: ComponentTopologyDigest,
765 actual: ComponentTopologyDigest,
766 },
767
768 #[error(
769 "root release set requires {required_bytes} Wasm Store bytes, exceeding limit {maximum_bytes}"
770 )]
771 WasmStoreLimitExceeded {
772 maximum_bytes: u64,
773 required_bytes: u64,
774 },
775
776 #[error("application artifact {role} {kind} size must be nonzero")]
777 ZeroSize {
778 role: CanisterRole,
779 kind: &'static str,
780 },
781}
782
783fn topology_roles(topology: &ComponentTopology) -> Vec<CanisterRole> {
784 topology
785 .component_specs
786 .iter()
787 .flat_map(|spec| {
788 std::iter::once(spec.component_role.clone())
789 .chain(spec.children.iter().map(|child| child.role.clone()))
790 })
791 .collect::<BTreeSet<_>>()
792 .into_iter()
793 .collect()
794}
795
796fn compile_entry(
797 release_build_id: ReleaseBuildId,
798 target: &ApplicationArtifactBuildTarget,
799 output: &ApplicationArtifactBuildOutput,
800) -> Result<ApplicationArtifactEntry, ApplicationReleaseSetError> {
801 if output.package != target.package {
802 return Err(ApplicationReleaseSetError::BuildOutputPackageMismatch {
803 role: output.role.clone(),
804 expected: target.package.clone(),
805 actual: output.package.clone(),
806 });
807 }
808 if output.wasm_relative_path != target.wasm_relative_path {
809 return Err(ApplicationReleaseSetError::BuildOutputPathMismatch {
810 role: output.role.clone(),
811 kind: "raw Wasm",
812 expected: target.wasm_relative_path.clone(),
813 actual: output.wasm_relative_path.clone(),
814 });
815 }
816 if output.wasm_gz_relative_path != target.wasm_gz_relative_path {
817 return Err(ApplicationReleaseSetError::BuildOutputPathMismatch {
818 role: output.role.clone(),
819 kind: "gzip Wasm",
820 expected: target.wasm_gz_relative_path.clone(),
821 actual: output.wasm_gz_relative_path.clone(),
822 });
823 }
824 if output.release_build_id != release_build_id {
825 return Err(ApplicationReleaseSetError::ReleaseBuildMismatch {
826 role: output.role.clone(),
827 expected: release_build_id,
828 actual: output.release_build_id,
829 });
830 }
831 if output.wasm.is_empty() {
832 return Err(ApplicationReleaseSetError::EmptyArtifact {
833 role: output.role.clone(),
834 kind: "raw Wasm",
835 });
836 }
837 if !output.wasm.starts_with(&WASM_MAGIC) {
838 return Err(ApplicationReleaseSetError::InvalidWasm {
839 role: output.role.clone(),
840 });
841 }
842 if output.wasm_gz.is_empty() {
843 return Err(ApplicationReleaseSetError::EmptyArtifact {
844 role: output.role.clone(),
845 kind: "gzip Wasm",
846 });
847 }
848 if !output.wasm_gz.starts_with(&GZIP_MAGIC) {
849 return Err(ApplicationReleaseSetError::InvalidGzip {
850 role: output.role.clone(),
851 source: std::io::Error::new(std::io::ErrorKind::InvalidData, "missing gzip header"),
852 });
853 }
854
855 let wasm_size_bytes = u64::try_from(output.wasm.len()).map_err(|_| {
856 ApplicationReleaseSetError::ArtifactSizeOverflow {
857 role: output.role.clone(),
858 kind: "raw Wasm",
859 }
860 })?;
861 let wasm_gz_size_bytes = u64::try_from(output.wasm_gz.len()).map_err(|_| {
862 ApplicationReleaseSetError::ArtifactSizeOverflow {
863 role: output.role.clone(),
864 kind: "gzip Wasm",
865 }
866 })?;
867 let mut decoded = Vec::new();
868 GzDecoder::new(output.wasm_gz.as_slice())
869 .take(wasm_size_bytes.saturating_add(1))
870 .read_to_end(&mut decoded)
871 .map_err(|source| ApplicationReleaseSetError::InvalidGzip {
872 role: output.role.clone(),
873 source,
874 })?;
875 if decoded != output.wasm {
876 return Err(ApplicationReleaseSetError::RepresentationMismatch {
877 role: output.role.clone(),
878 });
879 }
880
881 let entry = ApplicationArtifactEntry {
882 role: output.role.clone(),
883 package: output.package.clone(),
884 release_build_id: output.release_build_id,
885 wasm_relative_path: output.wasm_relative_path.clone(),
886 wasm_size_bytes,
887 wasm_sha256_hex: sha256_hex(&output.wasm),
888 wasm_gz_relative_path: output.wasm_gz_relative_path.clone(),
889 wasm_gz_size_bytes,
890 wasm_gz_sha256_hex: sha256_hex(&output.wasm_gz),
891 candid_sha256: output.candid_sha256,
892 protocol_profile_digest: output.protocol_profile_digest,
893 };
894 validate_entry(release_build_id, &entry)?;
895 Ok(entry)
896}
897
898fn validate_entry(
899 release_build_id: ReleaseBuildId,
900 entry: &ApplicationArtifactEntry,
901) -> Result<(), ApplicationReleaseSetError> {
902 if entry.release_build_id != release_build_id {
903 return Err(ApplicationReleaseSetError::ReleaseBuildMismatch {
904 role: entry.role.clone(),
905 expected: release_build_id,
906 actual: entry.release_build_id,
907 });
908 }
909 if !valid_package_name(&entry.package) {
910 return Err(ApplicationReleaseSetError::InvalidPackage {
911 role: entry.role.clone(),
912 package: entry.package.clone(),
913 });
914 }
915 validate_path(&entry.role, "raw Wasm", &entry.wasm_relative_path)?;
916 validate_path(&entry.role, "gzip Wasm", &entry.wasm_gz_relative_path)?;
917 if entry.wasm_size_bytes == 0 {
918 return Err(ApplicationReleaseSetError::ZeroSize {
919 role: entry.role.clone(),
920 kind: "raw Wasm",
921 });
922 }
923 if entry.wasm_gz_size_bytes == 0 {
924 return Err(ApplicationReleaseSetError::ZeroSize {
925 role: entry.role.clone(),
926 kind: "gzip Wasm",
927 });
928 }
929 validate_sha256(&entry.role, "raw Wasm", &entry.wasm_sha256_hex)?;
930 validate_sha256(&entry.role, "gzip Wasm", &entry.wasm_gz_sha256_hex)
931}
932
933fn validate_path(
934 role: &CanisterRole,
935 kind: &'static str,
936 path: &str,
937) -> Result<(), ApplicationReleaseSetError> {
938 if path.len() > MAX_ARTIFACT_PATH_BYTES
939 || validate_release_artifact_relative_path(path).is_err()
940 {
941 return Err(ApplicationReleaseSetError::InvalidPath {
942 role: role.clone(),
943 kind,
944 path: path.to_string(),
945 });
946 }
947 Ok(())
948}
949
950fn validate_sha256(
951 role: &CanisterRole,
952 kind: &'static str,
953 value: &str,
954) -> Result<(), ApplicationReleaseSetError> {
955 let canonical = value.len() == SHA_256_HEX_BYTES
956 && value
957 .bytes()
958 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
959 && decode_hex(value).is_ok();
960 if canonical {
961 Ok(())
962 } else {
963 Err(ApplicationReleaseSetError::InvalidSha256 {
964 role: role.clone(),
965 kind,
966 value: value.to_string(),
967 })
968 }
969}
970
971fn project_entry(
972 component_spec: &ComponentSpecId,
973 kind: ApplicationReleaseSetEntryKind,
974 role: &CanisterRole,
975 union: &ApplicationArtifactUnion,
976) -> Result<FleetSubnetRootReleaseSetEntry, ApplicationReleaseSetError> {
977 let artifact = union
978 .get(role)
979 .ok_or_else(|| ApplicationReleaseSetError::MissingArtifact { role: role.clone() })?;
980 Ok(FleetSubnetRootReleaseSetEntry {
981 component_spec: component_spec.clone(),
982 kind,
983 artifact: artifact.clone(),
984 })
985}
986
987fn expected_projection_entries(
988 topology: &ComponentTopology,
989 union: &ApplicationArtifactUnion,
990) -> Result<Vec<FleetSubnetRootReleaseSetEntry>, ApplicationReleaseSetError> {
991 let mut entries = Vec::new();
992 for component_spec in &topology.component_specs {
993 entries.push(project_entry(
994 &component_spec.component_spec,
995 ApplicationReleaseSetEntryKind::Component,
996 &component_spec.component_role,
997 union,
998 )?);
999 for child in &component_spec.children {
1000 entries.push(project_entry(
1001 &component_spec.component_spec,
1002 ApplicationReleaseSetEntryKind::ComponentChild,
1003 &child.role,
1004 union,
1005 )?);
1006 }
1007 }
1008 Ok(entries)
1009}
1010
1011fn project_fixtures(
1012 topology: &ComponentTopology,
1013 fixtures: &FixtureArtifactManifest,
1014) -> Vec<canic_core::dto::root_store::RootStoreFixture> {
1015 let roles = topology_roles(topology);
1016 fixtures
1017 .entries
1018 .iter()
1019 .filter(|entry| roles.contains(&entry.role))
1020 .map(|entry| canic_core::dto::root_store::RootStoreFixture {
1021 role: entry.role.clone(),
1022 content_id: entry.content_id,
1023 descriptor: entry.descriptor.clone(),
1024 })
1025 .collect()
1026}