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