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 pub fn project(
307 topology: &ComponentTopology,
308 binding: &FleetSubnetRootBinding,
309 union: &ApplicationArtifactUnion,
310 ) -> Result<Self, ApplicationReleaseSetError> {
311 Self::project_for_root(
312 topology,
313 &binding.component_admissions,
314 binding.component_topology_digest,
315 binding.limits.maximum_wasm_store_bytes,
316 union,
317 )
318 }
319
320 pub fn project_planned(
322 topology: &ComponentTopology,
323 root: &PlannedFleetSubnetRootTopology,
324 union: &ApplicationArtifactUnion,
325 ) -> Result<Self, ApplicationReleaseSetError> {
326 Self::project_for_root(
327 topology,
328 &root.component_admissions,
329 root.component_topology_digest,
330 root.limits.maximum_wasm_store_bytes,
331 union,
332 )
333 }
334
335 fn project_for_root(
336 topology: &ComponentTopology,
337 component_admissions: &[canic_core::ids::ComponentSpecAdmission],
338 component_topology_digest: ComponentTopologyDigest,
339 maximum_wasm_store_bytes: u64,
340 union: &ApplicationArtifactUnion,
341 ) -> Result<Self, ApplicationReleaseSetError> {
342 union.validate_against(topology)?;
343 let projected = topology.project_for_admissions(component_admissions)?;
344 let digest = projected.digest()?;
345 if digest != component_topology_digest {
346 return Err(ApplicationReleaseSetError::RootTopologyDigestMismatch {
347 expected: digest,
348 actual: component_topology_digest,
349 });
350 }
351
352 let mut entries = Vec::new();
353 for component_spec in &projected.component_specs {
354 entries.push(project_entry(
355 &component_spec.component_spec,
356 ApplicationReleaseSetEntryKind::Component,
357 &component_spec.component_role,
358 union,
359 )?);
360 for child in &component_spec.children {
361 entries.push(project_entry(
362 &component_spec.component_spec,
363 ApplicationReleaseSetEntryKind::ComponentChild,
364 &child.role,
365 union,
366 )?);
367 }
368 }
369
370 let manifest = Self {
371 release_build_id: union.release_build_id,
372 component_topology_digest: digest,
373 entries,
374 };
375 manifest.validate_for_root(
376 topology,
377 component_admissions,
378 component_topology_digest,
379 maximum_wasm_store_bytes,
380 union,
381 )?;
382 Ok(manifest)
383 }
384
385 pub fn validate_against(
387 &self,
388 topology: &ComponentTopology,
389 binding: &FleetSubnetRootBinding,
390 union: &ApplicationArtifactUnion,
391 ) -> Result<(), ApplicationReleaseSetError> {
392 self.validate_for_root(
393 topology,
394 &binding.component_admissions,
395 binding.component_topology_digest,
396 binding.limits.maximum_wasm_store_bytes,
397 union,
398 )
399 }
400
401 pub fn validate_against_planned(
403 &self,
404 topology: &ComponentTopology,
405 root: &PlannedFleetSubnetRootTopology,
406 union: &ApplicationArtifactUnion,
407 ) -> Result<(), ApplicationReleaseSetError> {
408 self.validate_for_root(
409 topology,
410 &root.component_admissions,
411 root.component_topology_digest,
412 root.limits.maximum_wasm_store_bytes,
413 union,
414 )
415 }
416
417 fn validate_for_root(
418 &self,
419 topology: &ComponentTopology,
420 component_admissions: &[canic_core::ids::ComponentSpecAdmission],
421 component_topology_digest: ComponentTopologyDigest,
422 maximum_wasm_store_bytes: u64,
423 union: &ApplicationArtifactUnion,
424 ) -> Result<(), ApplicationReleaseSetError> {
425 union.validate_against(topology)?;
426 if self.release_build_id != union.release_build_id {
427 return Err(ApplicationReleaseSetError::ManifestBuildMismatch {
428 expected: union.release_build_id,
429 actual: self.release_build_id,
430 });
431 }
432
433 let projected = topology.project_for_admissions(component_admissions)?;
434 let digest = projected.digest()?;
435 if component_topology_digest != digest {
436 return Err(ApplicationReleaseSetError::RootTopologyDigestMismatch {
437 expected: digest,
438 actual: component_topology_digest,
439 });
440 }
441 if self.component_topology_digest != digest {
442 return Err(ApplicationReleaseSetError::ManifestTopologyDigestMismatch {
443 expected: digest,
444 actual: self.component_topology_digest,
445 });
446 }
447
448 let expected = expected_projection_entries(&projected, union)?;
449 if self.entries != expected {
450 return Err(ApplicationReleaseSetError::ProjectionMismatch);
451 }
452
453 let mut unique_artifacts = BTreeSet::new();
454 let total_bytes = self.entries.iter().try_fold(0_u64, |total, entry| {
455 let artifact = &entry.artifact;
456 if unique_artifacts.insert((
457 artifact.wasm_size_bytes,
458 artifact.wasm_sha256_hex.as_str(),
459 artifact.wasm_gz_size_bytes,
460 artifact.wasm_gz_sha256_hex.as_str(),
461 )) {
462 total
463 .checked_add(artifact.wasm_gz_size_bytes)
464 .ok_or(ApplicationReleaseSetError::StoreBytesOverflow)
465 } else {
466 Ok(total)
467 }
468 })?;
469 if total_bytes > maximum_wasm_store_bytes {
470 return Err(ApplicationReleaseSetError::WasmStoreLimitExceeded {
471 maximum_bytes: maximum_wasm_store_bytes,
472 required_bytes: total_bytes,
473 });
474 }
475
476 Ok(())
477 }
478
479 pub fn canonical_bytes(
481 &self,
482 topology: &ComponentTopology,
483 binding: &FleetSubnetRootBinding,
484 union: &ApplicationArtifactUnion,
485 ) -> Result<Vec<u8>, ApplicationReleaseSetError> {
486 self.validate_against(topology, binding, union)?;
487 serde_json::to_vec(self).map_err(ApplicationReleaseSetError::Serialization)
488 }
489
490 pub fn digest(
492 &self,
493 topology: &ComponentTopology,
494 binding: &FleetSubnetRootBinding,
495 union: &ApplicationArtifactUnion,
496 ) -> Result<ReleaseSetDigest, ApplicationReleaseSetError> {
497 Ok(ReleaseSetDigest::from_bytes(
498 Sha256::digest(self.canonical_bytes(topology, binding, union)?).into(),
499 ))
500 }
501
502 pub fn canonical_bytes_planned(
504 &self,
505 topology: &ComponentTopology,
506 root: &PlannedFleetSubnetRootTopology,
507 union: &ApplicationArtifactUnion,
508 ) -> Result<Vec<u8>, ApplicationReleaseSetError> {
509 self.validate_against_planned(topology, root, union)?;
510 serde_json::to_vec(self).map_err(ApplicationReleaseSetError::Serialization)
511 }
512
513 pub fn digest_planned(
515 &self,
516 topology: &ComponentTopology,
517 root: &PlannedFleetSubnetRootTopology,
518 union: &ApplicationArtifactUnion,
519 ) -> Result<ReleaseSetDigest, ApplicationReleaseSetError> {
520 Ok(ReleaseSetDigest::from_bytes(
521 Sha256::digest(self.canonical_bytes_planned(topology, root, union)?).into(),
522 ))
523 }
524}
525
526#[derive(Debug, ThisError)]
533pub enum ApplicationReleaseSetError {
534 #[error("application artifact {role} {kind} size cannot be represented")]
535 ArtifactSizeOverflow {
536 role: CanisterRole,
537 kind: &'static str,
538 },
539
540 #[error("application artifact union role set differs from Component Topology")]
541 ArtifactUnionRoleSet {
542 expected: Vec<CanisterRole>,
543 actual: Vec<CanisterRole>,
544 },
545
546 #[error("application build output {role} package {actual} does not match target {expected}")]
547 BuildOutputPackageMismatch {
548 role: CanisterRole,
549 expected: String,
550 actual: String,
551 },
552
553 #[error(
554 "application build output {role} {kind} path {actual} does not match target {expected}"
555 )]
556 BuildOutputPathMismatch {
557 role: CanisterRole,
558 kind: &'static str,
559 expected: String,
560 actual: String,
561 },
562
563 #[error("application build output role set differs from Component Topology")]
564 BuildOutputRoleSet {
565 expected: Vec<CanisterRole>,
566 actual: Vec<CanisterRole>,
567 },
568
569 #[error("application build target role set differs from Component Topology")]
570 BuildTargetRoleSet {
571 expected: Vec<CanisterRole>,
572 actual: Vec<CanisterRole>,
573 },
574
575 #[error("application artifact path is duplicated: {path}")]
576 DuplicateArtifactPath { path: String },
577
578 #[error("application artifact union must not be empty")]
579 EmptyArtifactUnion,
580
581 #[error("application artifact {role} has an empty {kind} payload")]
582 EmptyArtifact {
583 role: CanisterRole,
584 kind: &'static str,
585 },
586
587 #[error("application artifact {role} has invalid gzip Wasm: {source}")]
588 InvalidGzip {
589 role: CanisterRole,
590 #[source]
591 source: std::io::Error,
592 },
593
594 #[error("application artifact {role} has an invalid package identity: {package}")]
595 InvalidPackage { role: CanisterRole, package: String },
596
597 #[error("application artifact {role} has an invalid {kind} path: {path}")]
598 InvalidPath {
599 role: CanisterRole,
600 kind: &'static str,
601 path: String,
602 },
603
604 #[error("application artifact {role} has an invalid {kind} SHA-256: {value}")]
605 InvalidSha256 {
606 role: CanisterRole,
607 kind: &'static str,
608 value: String,
609 },
610
611 #[error("application artifact {role} does not begin with the Wasm magic bytes")]
612 InvalidWasm { role: CanisterRole },
613
614 #[error(
615 "application release-set manifest build {actual} does not match artifact union build {expected}"
616 )]
617 ManifestBuildMismatch {
618 expected: ReleaseBuildId,
619 actual: ReleaseBuildId,
620 },
621
622 #[error("application release-set manifest topology digest differs from its root projection")]
623 ManifestTopologyDigestMismatch {
624 expected: ComponentTopologyDigest,
625 actual: ComponentTopologyDigest,
626 },
627
628 #[error("application artifact union has no build target for role {role}")]
629 MissingBuildTarget { role: CanisterRole },
630
631 #[error("application artifact union has no qualified artifact for role {role}")]
632 MissingArtifact { role: CanisterRole },
633
634 #[error("application artifact order is not canonical: {previous} before {current}")]
635 NonCanonicalArtifactOrder {
636 previous: CanisterRole,
637 current: CanisterRole,
638 },
639
640 #[error("application release-set manifest differs from the exact admitted projection")]
641 ProjectionMismatch,
642
643 #[error(
644 "application artifact {role} release build {actual} does not match union build {expected}"
645 )]
646 ReleaseBuildMismatch {
647 role: CanisterRole,
648 expected: ReleaseBuildId,
649 actual: ReleaseBuildId,
650 },
651
652 #[error("application artifact {role} raw and gzip Wasm representations differ")]
653 RepresentationMismatch { role: CanisterRole },
654
655 #[error("Fleet Subnet Root topology digest differs from its admitted projection")]
656 RootTopologyDigestMismatch {
657 expected: ComponentTopologyDigest,
658 actual: ComponentTopologyDigest,
659 },
660
661 #[error("failed to serialize application release-set evidence: {0}")]
662 Serialization(serde_json::Error),
663
664 #[error("root release-set artifact byte total overflowed")]
665 StoreBytesOverflow,
666
667 #[error(transparent)]
668 Topology(#[from] ComponentTopologyError),
669
670 #[error("application artifact union topology digest differs from Component Topology")]
671 UnionTopologyDigestMismatch {
672 expected: ComponentTopologyDigest,
673 actual: ComponentTopologyDigest,
674 },
675
676 #[error(
677 "root release set requires {required_bytes} Wasm Store bytes, exceeding limit {maximum_bytes}"
678 )]
679 WasmStoreLimitExceeded {
680 maximum_bytes: u64,
681 required_bytes: u64,
682 },
683
684 #[error("application artifact {role} {kind} size must be nonzero")]
685 ZeroSize {
686 role: CanisterRole,
687 kind: &'static str,
688 },
689}
690
691fn topology_roles(topology: &ComponentTopology) -> Vec<CanisterRole> {
692 topology
693 .component_specs
694 .iter()
695 .flat_map(|spec| {
696 std::iter::once(spec.component_role.clone())
697 .chain(spec.children.iter().map(|child| child.role.clone()))
698 })
699 .collect::<BTreeSet<_>>()
700 .into_iter()
701 .collect()
702}
703
704fn compile_entry(
705 release_build_id: ReleaseBuildId,
706 target: &ApplicationArtifactBuildTarget,
707 output: &ApplicationArtifactBuildOutput,
708) -> Result<ApplicationArtifactEntry, ApplicationReleaseSetError> {
709 if output.package != target.package {
710 return Err(ApplicationReleaseSetError::BuildOutputPackageMismatch {
711 role: output.role.clone(),
712 expected: target.package.clone(),
713 actual: output.package.clone(),
714 });
715 }
716 if output.wasm_relative_path != target.wasm_relative_path {
717 return Err(ApplicationReleaseSetError::BuildOutputPathMismatch {
718 role: output.role.clone(),
719 kind: "raw Wasm",
720 expected: target.wasm_relative_path.clone(),
721 actual: output.wasm_relative_path.clone(),
722 });
723 }
724 if output.wasm_gz_relative_path != target.wasm_gz_relative_path {
725 return Err(ApplicationReleaseSetError::BuildOutputPathMismatch {
726 role: output.role.clone(),
727 kind: "gzip Wasm",
728 expected: target.wasm_gz_relative_path.clone(),
729 actual: output.wasm_gz_relative_path.clone(),
730 });
731 }
732 if output.release_build_id != release_build_id {
733 return Err(ApplicationReleaseSetError::ReleaseBuildMismatch {
734 role: output.role.clone(),
735 expected: release_build_id,
736 actual: output.release_build_id,
737 });
738 }
739 if output.wasm.is_empty() {
740 return Err(ApplicationReleaseSetError::EmptyArtifact {
741 role: output.role.clone(),
742 kind: "raw Wasm",
743 });
744 }
745 if !output.wasm.starts_with(&WASM_MAGIC) {
746 return Err(ApplicationReleaseSetError::InvalidWasm {
747 role: output.role.clone(),
748 });
749 }
750 if output.wasm_gz.is_empty() {
751 return Err(ApplicationReleaseSetError::EmptyArtifact {
752 role: output.role.clone(),
753 kind: "gzip Wasm",
754 });
755 }
756 if !output.wasm_gz.starts_with(&GZIP_MAGIC) {
757 return Err(ApplicationReleaseSetError::InvalidGzip {
758 role: output.role.clone(),
759 source: std::io::Error::new(std::io::ErrorKind::InvalidData, "missing gzip header"),
760 });
761 }
762
763 let wasm_size_bytes = u64::try_from(output.wasm.len()).map_err(|_| {
764 ApplicationReleaseSetError::ArtifactSizeOverflow {
765 role: output.role.clone(),
766 kind: "raw Wasm",
767 }
768 })?;
769 let wasm_gz_size_bytes = u64::try_from(output.wasm_gz.len()).map_err(|_| {
770 ApplicationReleaseSetError::ArtifactSizeOverflow {
771 role: output.role.clone(),
772 kind: "gzip Wasm",
773 }
774 })?;
775 let mut decoded = Vec::new();
776 GzDecoder::new(output.wasm_gz.as_slice())
777 .take(wasm_size_bytes.saturating_add(1))
778 .read_to_end(&mut decoded)
779 .map_err(|source| ApplicationReleaseSetError::InvalidGzip {
780 role: output.role.clone(),
781 source,
782 })?;
783 if decoded != output.wasm {
784 return Err(ApplicationReleaseSetError::RepresentationMismatch {
785 role: output.role.clone(),
786 });
787 }
788
789 let entry = ApplicationArtifactEntry {
790 role: output.role.clone(),
791 package: output.package.clone(),
792 release_build_id: output.release_build_id,
793 wasm_relative_path: output.wasm_relative_path.clone(),
794 wasm_size_bytes,
795 wasm_sha256_hex: sha256_hex(&output.wasm),
796 wasm_gz_relative_path: output.wasm_gz_relative_path.clone(),
797 wasm_gz_size_bytes,
798 wasm_gz_sha256_hex: sha256_hex(&output.wasm_gz),
799 };
800 validate_entry(release_build_id, &entry)?;
801 Ok(entry)
802}
803
804fn validate_entry(
805 release_build_id: ReleaseBuildId,
806 entry: &ApplicationArtifactEntry,
807) -> Result<(), ApplicationReleaseSetError> {
808 if entry.release_build_id != release_build_id {
809 return Err(ApplicationReleaseSetError::ReleaseBuildMismatch {
810 role: entry.role.clone(),
811 expected: release_build_id,
812 actual: entry.release_build_id,
813 });
814 }
815 if !valid_package_name(&entry.package) {
816 return Err(ApplicationReleaseSetError::InvalidPackage {
817 role: entry.role.clone(),
818 package: entry.package.clone(),
819 });
820 }
821 validate_path(&entry.role, "raw Wasm", &entry.wasm_relative_path)?;
822 validate_path(&entry.role, "gzip Wasm", &entry.wasm_gz_relative_path)?;
823 if entry.wasm_size_bytes == 0 {
824 return Err(ApplicationReleaseSetError::ZeroSize {
825 role: entry.role.clone(),
826 kind: "raw Wasm",
827 });
828 }
829 if entry.wasm_gz_size_bytes == 0 {
830 return Err(ApplicationReleaseSetError::ZeroSize {
831 role: entry.role.clone(),
832 kind: "gzip Wasm",
833 });
834 }
835 validate_sha256(&entry.role, "raw Wasm", &entry.wasm_sha256_hex)?;
836 validate_sha256(&entry.role, "gzip Wasm", &entry.wasm_gz_sha256_hex)
837}
838
839fn validate_path(
840 role: &CanisterRole,
841 kind: &'static str,
842 path: &str,
843) -> Result<(), ApplicationReleaseSetError> {
844 if path.len() > MAX_ARTIFACT_PATH_BYTES
845 || validate_release_artifact_relative_path(path).is_err()
846 {
847 return Err(ApplicationReleaseSetError::InvalidPath {
848 role: role.clone(),
849 kind,
850 path: path.to_string(),
851 });
852 }
853 Ok(())
854}
855
856fn validate_sha256(
857 role: &CanisterRole,
858 kind: &'static str,
859 value: &str,
860) -> Result<(), ApplicationReleaseSetError> {
861 let canonical = value.len() == SHA_256_HEX_BYTES
862 && value
863 .bytes()
864 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
865 && decode_hex(value).is_ok();
866 if canonical {
867 Ok(())
868 } else {
869 Err(ApplicationReleaseSetError::InvalidSha256 {
870 role: role.clone(),
871 kind,
872 value: value.to_string(),
873 })
874 }
875}
876
877fn valid_package_name(package: &str) -> bool {
878 let bytes = package.as_bytes();
879 !bytes.is_empty()
880 && bytes.len() <= MAX_PACKAGE_BYTES
881 && bytes.first().is_some_and(u8::is_ascii_alphanumeric)
882 && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
883 && bytes
884 .iter()
885 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
886}
887
888fn project_entry(
889 component_spec: &ComponentSpecId,
890 kind: ApplicationReleaseSetEntryKind,
891 role: &CanisterRole,
892 union: &ApplicationArtifactUnion,
893) -> Result<FleetSubnetRootReleaseSetEntry, ApplicationReleaseSetError> {
894 let artifact = union
895 .get(role)
896 .ok_or_else(|| ApplicationReleaseSetError::MissingArtifact { role: role.clone() })?;
897 Ok(FleetSubnetRootReleaseSetEntry {
898 component_spec: component_spec.clone(),
899 kind,
900 artifact: artifact.clone(),
901 })
902}
903
904fn expected_projection_entries(
905 topology: &ComponentTopology,
906 union: &ApplicationArtifactUnion,
907) -> Result<Vec<FleetSubnetRootReleaseSetEntry>, ApplicationReleaseSetError> {
908 let mut entries = Vec::new();
909 for component_spec in &topology.component_specs {
910 entries.push(project_entry(
911 &component_spec.component_spec,
912 ApplicationReleaseSetEntryKind::Component,
913 &component_spec.component_role,
914 union,
915 )?);
916 for child in &component_spec.children {
917 entries.push(project_entry(
918 &component_spec.component_spec,
919 ApplicationReleaseSetEntryKind::ComponentChild,
920 &child.role,
921 union,
922 )?);
923 }
924 }
925 Ok(entries)
926}