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