Skip to main content

canic_host/release_set/application/
mod.rs

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