Skip to main content

canic_host/release_set/infrastructure/
mod.rs

1//! Module: release_set::infrastructure
2//!
3//! Responsibility: compile and validate the exact Canic infrastructure artifact manifest.
4//! Does not own: Cargo execution, placement, installation, or application release sets.
5//! Boundary: derives immutable artifact evidence from one qualified release build.
6
7mod persistence;
8#[cfg(test)]
9mod tests;
10
11use crate::release_set::{
12    artifact::{RepresentationError, qualify_representation},
13    valid_package_name, validate_release_artifact_relative_path,
14};
15use canic_contracts::ids::{CanisterRole, ReleaseBuildId};
16use canic_core::{
17    cdk::utils::hash::decode_hex,
18    role_contract::{ProtocolProfileDigest, RoleCapabilityKey},
19};
20use serde::{Deserialize, Serialize};
21use sha2_host::{Digest, Sha256};
22use std::collections::BTreeSet;
23use thiserror::Error as ThisError;
24
25pub use persistence::{
26    CanicInfrastructureArtifactBuildOutput, CanicInfrastructureArtifactPersistenceError,
27    PersistedCanicInfrastructureArtifactManifest,
28    compile_and_persist_canic_infrastructure_artifact_manifest,
29    load_persisted_canic_infrastructure_artifact_manifest,
30};
31
32const SHA_256_HEX_BYTES: usize = 64;
33const MAX_ARTIFACT_PATH_BYTES: usize = 4_096;
34const REQUIRED_INFRASTRUCTURE_ROLES: [CanicInfrastructureRole; 3] = [
35    CanicInfrastructureRole::FleetCoordinator,
36    CanicInfrastructureRole::FleetSubnetRoot,
37    CanicInfrastructureRole::WasmStore,
38];
39
40///
41/// CanicInfrastructureRole
42///
43/// Exact infrastructure artifact roles installed outside Component topology.
44///
45
46#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
47#[serde(rename_all = "snake_case")]
48pub enum CanicInfrastructureRole {
49    FleetCoordinator,
50    FleetSubnetRoot,
51    WasmStore,
52}
53
54impl CanicInfrastructureRole {
55    /// Return the canonical role identity used in artifact paths and evidence.
56    #[must_use]
57    pub const fn as_str(self) -> &'static str {
58        match self {
59            Self::FleetCoordinator => "fleet_coordinator",
60            Self::FleetSubnetRoot => "fleet_subnet_root",
61            Self::WasmStore => "wasm_store",
62        }
63    }
64
65    /// Return the canonical compiled protocol role selected by this artifact.
66    #[must_use]
67    pub const fn protocol_role_name(self) -> &'static str {
68        match self {
69            Self::FleetCoordinator => "fleet_coordinator",
70            Self::FleetSubnetRoot => "root",
71            Self::WasmStore => "wasm_store",
72        }
73    }
74}
75
76///
77/// CanicInfrastructureArtifactInput
78///
79/// One current-build artifact supplied to the manifest compiler.
80///
81
82#[derive(Clone, Copy, Debug)]
83pub struct CanicInfrastructureArtifactInput<'a> {
84    pub role: CanicInfrastructureRole,
85    pub package: &'a str,
86    pub protocol_release_identity: &'a str,
87    pub protocol_role: &'a CanisterRole,
88    pub protocol_capabilities: &'a BTreeSet<RoleCapabilityKey>,
89    pub release_build_id: ReleaseBuildId,
90    pub wasm_relative_path: &'a str,
91    pub wasm: &'a [u8],
92    pub wasm_gz_relative_path: &'a str,
93    pub wasm_gz: &'a [u8],
94    pub candid_sha256: [u8; 32],
95    pub protocol_profile_digest: ProtocolProfileDigest,
96}
97
98///
99/// CanicInfrastructureArtifactManifest
100///
101/// Canonical infrastructure and temporary-support artifact authority for one release build.
102///
103
104#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
105#[serde(deny_unknown_fields)]
106pub struct CanicInfrastructureArtifactManifest {
107    pub release_build_id: ReleaseBuildId,
108    pub entries: Vec<CanicInfrastructureArtifactEntry>,
109}
110
111impl CanicInfrastructureArtifactManifest {
112    /// Compile exact artifact evidence without accepting caller-supplied hashes.
113    pub fn compile(
114        release_build_id: ReleaseBuildId,
115        inputs: &[CanicInfrastructureArtifactInput<'_>],
116    ) -> Result<Self, CanicInfrastructureArtifactManifestError> {
117        let mut entries = inputs
118            .iter()
119            .map(|input| compile_entry(release_build_id, input))
120            .collect::<Result<Vec<_>, _>>()?;
121        entries.sort_unstable_by_key(|entry| entry.role);
122
123        let manifest = Self {
124            release_build_id,
125            entries,
126        };
127        manifest.validate()?;
128        Ok(manifest)
129    }
130
131    /// Validate the canonical role, build, package, path, size, and digest shape.
132    pub fn validate(&self) -> Result<(), CanicInfrastructureArtifactManifestError> {
133        let actual_roles = self
134            .entries
135            .iter()
136            .map(|entry| entry.role)
137            .collect::<Vec<_>>();
138        if actual_roles != REQUIRED_INFRASTRUCTURE_ROLES {
139            return Err(
140                CanicInfrastructureArtifactManifestError::InfrastructureRoleSet {
141                    actual: actual_roles,
142                },
143            );
144        }
145
146        let mut paths = BTreeSet::new();
147        for entry in &self.entries {
148            validate_entry(self.release_build_id, entry)?;
149            for path in [&entry.wasm_relative_path, &entry.wasm_gz_relative_path] {
150                if !paths.insert(path.as_str()) {
151                    return Err(
152                        CanicInfrastructureArtifactManifestError::DuplicateArtifactPath {
153                            path: path.clone(),
154                        },
155                    );
156                }
157            }
158        }
159
160        Ok(())
161    }
162
163    /// Encode the validated manifest into deterministic compact JSON bytes.
164    pub fn canonical_bytes(&self) -> Result<Vec<u8>, CanicInfrastructureArtifactManifestError> {
165        self.validate()?;
166        serde_json::to_vec(self).map_err(CanicInfrastructureArtifactManifestError::Serialization)
167    }
168
169    /// Hash the exact canonical manifest bytes.
170    pub fn digest(&self) -> Result<[u8; 32], CanicInfrastructureArtifactManifestError> {
171        Ok(Sha256::digest(self.canonical_bytes()?).into())
172    }
173}
174
175///
176/// CanicInfrastructureArtifactEntry
177///
178/// Exact package, release-build, path, size, and digest evidence for one role.
179///
180
181#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
182#[serde(deny_unknown_fields)]
183pub struct CanicInfrastructureArtifactEntry {
184    pub role: CanicInfrastructureRole,
185    pub package: String,
186    pub protocol_release_identity: String,
187    pub protocol_role: CanisterRole,
188    pub protocol_capabilities: BTreeSet<RoleCapabilityKey>,
189    pub release_build_id: ReleaseBuildId,
190    pub wasm_relative_path: String,
191    pub wasm_size_bytes: u64,
192    pub wasm_sha256_hex: String,
193    pub wasm_gz_relative_path: String,
194    pub wasm_gz_size_bytes: u64,
195    pub wasm_gz_sha256_hex: String,
196    pub candid_sha256: [u8; 32],
197    pub protocol_profile_digest: ProtocolProfileDigest,
198}
199
200///
201/// CanicInfrastructureArtifactManifestError
202///
203/// Typed rejection at the infrastructure artifact authority boundary.
204///
205
206#[derive(Debug, ThisError)]
207pub enum CanicInfrastructureArtifactManifestError {
208    #[error("infrastructure artifact {role:?} {kind} size cannot be represented")]
209    ArtifactSizeOverflow {
210        role: CanicInfrastructureRole,
211        kind: &'static str,
212    },
213
214    #[error("infrastructure artifact path is duplicated: {path}")]
215    DuplicateArtifactPath { path: String },
216
217    #[error("infrastructure artifact {role:?} has an empty {kind} payload")]
218    EmptyArtifact {
219        role: CanicInfrastructureRole,
220        kind: &'static str,
221    },
222
223    #[error("infrastructure artifact {role:?} has invalid gzip Wasm: {source}")]
224    InvalidGzip {
225        role: CanicInfrastructureRole,
226        #[source]
227        source: std::io::Error,
228    },
229
230    #[error(
231        "infrastructure manifest must contain each exact role once in canonical order: {actual:?}"
232    )]
233    InfrastructureRoleSet {
234        actual: Vec<CanicInfrastructureRole>,
235    },
236
237    #[error("infrastructure artifact {role:?} has invalid package identity: {package}")]
238    InvalidPackage {
239        role: CanicInfrastructureRole,
240        package: String,
241    },
242
243    #[error("infrastructure artifact {role:?} has an empty protocol release identity")]
244    MissingProtocolReleaseIdentity { role: CanicInfrastructureRole },
245
246    #[error(
247        "infrastructure artifact {role:?} protocol role {protocol_role} does not match its artifact role"
248    )]
249    ProtocolRoleMismatch {
250        role: CanicInfrastructureRole,
251        protocol_role: CanisterRole,
252    },
253
254    #[error("infrastructure artifact {role:?} has invalid {kind} path: {path}")]
255    InvalidPath {
256        role: CanicInfrastructureRole,
257        kind: &'static str,
258        path: String,
259    },
260
261    #[error("infrastructure artifact {role:?} has invalid {kind} SHA-256: {value}")]
262    InvalidSha256 {
263        role: CanicInfrastructureRole,
264        kind: &'static str,
265        value: String,
266    },
267
268    #[error("infrastructure artifact {role:?} has invalid raw Wasm bytes")]
269    InvalidWasm { role: CanicInfrastructureRole },
270
271    #[error(
272        "infrastructure artifact {role:?} release build {actual} does not match manifest release build {expected}"
273    )]
274    ReleaseBuildMismatch {
275        role: CanicInfrastructureRole,
276        expected: ReleaseBuildId,
277        actual: ReleaseBuildId,
278    },
279
280    #[error("infrastructure artifact {role:?} raw and gzip Wasm representations differ")]
281    RepresentationMismatch { role: CanicInfrastructureRole },
282
283    #[error("failed to serialize infrastructure artifact manifest: {0}")]
284    Serialization(serde_json::Error),
285
286    #[error("infrastructure artifact {role:?} {kind} size must be nonzero")]
287    ZeroSize {
288        role: CanicInfrastructureRole,
289        kind: &'static str,
290    },
291}
292
293fn compile_entry(
294    release_build_id: ReleaseBuildId,
295    input: &CanicInfrastructureArtifactInput<'_>,
296) -> Result<CanicInfrastructureArtifactEntry, CanicInfrastructureArtifactManifestError> {
297    if input.release_build_id != release_build_id {
298        return Err(
299            CanicInfrastructureArtifactManifestError::ReleaseBuildMismatch {
300                role: input.role,
301                expected: release_build_id,
302                actual: input.release_build_id,
303            },
304        );
305    }
306    let qualified =
307        qualify_representation(input.wasm, input.wasm_gz).map_err(|error| match error {
308            RepresentationError::EmptyArtifact { kind } => {
309                CanicInfrastructureArtifactManifestError::EmptyArtifact {
310                    role: input.role,
311                    kind,
312                }
313            }
314            RepresentationError::InvalidWasm => {
315                CanicInfrastructureArtifactManifestError::InvalidWasm { role: input.role }
316            }
317            RepresentationError::InvalidGzip { source } => {
318                CanicInfrastructureArtifactManifestError::InvalidGzip {
319                    role: input.role,
320                    source,
321                }
322            }
323            RepresentationError::ArtifactSizeOverflow { kind } => {
324                CanicInfrastructureArtifactManifestError::ArtifactSizeOverflow {
325                    role: input.role,
326                    kind,
327                }
328            }
329            RepresentationError::RepresentationMismatch => {
330                CanicInfrastructureArtifactManifestError::RepresentationMismatch {
331                    role: input.role,
332                }
333            }
334        })?;
335
336    let entry = CanicInfrastructureArtifactEntry {
337        role: input.role,
338        package: input.package.to_string(),
339        protocol_release_identity: input.protocol_release_identity.to_string(),
340        protocol_role: input.protocol_role.clone(),
341        protocol_capabilities: input.protocol_capabilities.clone(),
342        release_build_id: input.release_build_id,
343        wasm_relative_path: input.wasm_relative_path.to_string(),
344        wasm_size_bytes: qualified.wasm_size_bytes,
345        wasm_sha256_hex: qualified.wasm_sha256_hex,
346        wasm_gz_relative_path: input.wasm_gz_relative_path.to_string(),
347        wasm_gz_size_bytes: qualified.wasm_gz_size_bytes,
348        wasm_gz_sha256_hex: qualified.wasm_gz_sha256_hex,
349        candid_sha256: input.candid_sha256,
350        protocol_profile_digest: input.protocol_profile_digest,
351    };
352    validate_entry(release_build_id, &entry)?;
353    Ok(entry)
354}
355
356fn validate_entry(
357    release_build_id: ReleaseBuildId,
358    entry: &CanicInfrastructureArtifactEntry,
359) -> Result<(), CanicInfrastructureArtifactManifestError> {
360    if entry.release_build_id != release_build_id {
361        return Err(
362            CanicInfrastructureArtifactManifestError::ReleaseBuildMismatch {
363                role: entry.role,
364                expected: release_build_id,
365                actual: entry.release_build_id,
366            },
367        );
368    }
369    if !valid_package_name(&entry.package) {
370        return Err(CanicInfrastructureArtifactManifestError::InvalidPackage {
371            role: entry.role,
372            package: entry.package.clone(),
373        });
374    }
375    if entry.protocol_release_identity.trim().is_empty() {
376        return Err(
377            CanicInfrastructureArtifactManifestError::MissingProtocolReleaseIdentity {
378                role: entry.role,
379            },
380        );
381    }
382    if entry.protocol_role.as_str() != entry.role.protocol_role_name() {
383        return Err(
384            CanicInfrastructureArtifactManifestError::ProtocolRoleMismatch {
385                role: entry.role,
386                protocol_role: entry.protocol_role.clone(),
387            },
388        );
389    }
390    validate_path(entry.role, "raw Wasm", &entry.wasm_relative_path)?;
391    validate_path(entry.role, "gzip Wasm", &entry.wasm_gz_relative_path)?;
392    if entry.wasm_size_bytes == 0 {
393        return Err(CanicInfrastructureArtifactManifestError::ZeroSize {
394            role: entry.role,
395            kind: "raw Wasm",
396        });
397    }
398    if entry.wasm_gz_size_bytes == 0 {
399        return Err(CanicInfrastructureArtifactManifestError::ZeroSize {
400            role: entry.role,
401            kind: "gzip Wasm",
402        });
403    }
404    validate_sha256(entry.role, "raw Wasm", &entry.wasm_sha256_hex)?;
405    validate_sha256(entry.role, "gzip Wasm", &entry.wasm_gz_sha256_hex)
406}
407
408fn validate_path(
409    role: CanicInfrastructureRole,
410    kind: &'static str,
411    path: &str,
412) -> Result<(), CanicInfrastructureArtifactManifestError> {
413    if path.len() > MAX_ARTIFACT_PATH_BYTES
414        || validate_release_artifact_relative_path(path).is_err()
415    {
416        return Err(CanicInfrastructureArtifactManifestError::InvalidPath {
417            role,
418            kind,
419            path: path.to_string(),
420        });
421    }
422    Ok(())
423}
424
425fn validate_sha256(
426    role: CanicInfrastructureRole,
427    kind: &'static str,
428    value: &str,
429) -> Result<(), CanicInfrastructureArtifactManifestError> {
430    let canonical = value.len() == SHA_256_HEX_BYTES
431        && value
432            .bytes()
433            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
434        && decode_hex(value).is_ok();
435    if canonical {
436        Ok(())
437    } else {
438        Err(CanicInfrastructureArtifactManifestError::InvalidSha256 {
439            role,
440            kind,
441            value: value.to_string(),
442        })
443    }
444}