Skip to main content

canic_host/release_set/application/
mod.rs

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