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