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