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