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