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