Skip to main content

canic_host/release_set/application/
mod.rs

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