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 std::{collections::BTreeSet, io::Read};
12
13use canic_core::{
14    cdk::utils::hash::{decode_hex, sha256_hex},
15    ids::{CanisterRole, ReleaseBuildId},
16    role_contract::{ProtocolProfileDigest, RoleCapabilityKey},
17};
18use flate2::read::GzDecoder;
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21use thiserror::Error as ThisError;
22
23use super::{GZIP_MAGIC, WASM_MAGIC, valid_package_name, validate_release_artifact_relative_path};
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 three-entry artifact authority for one Canic 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    if input.wasm.is_empty() {
307        return Err(CanicInfrastructureArtifactManifestError::EmptyArtifact {
308            role: input.role,
309            kind: "raw Wasm",
310        });
311    }
312    if !input.wasm.starts_with(&WASM_MAGIC) {
313        return Err(CanicInfrastructureArtifactManifestError::InvalidWasm { role: input.role });
314    }
315    if input.wasm_gz.is_empty() {
316        return Err(CanicInfrastructureArtifactManifestError::EmptyArtifact {
317            role: input.role,
318            kind: "gzip Wasm",
319        });
320    }
321    if !input.wasm_gz.starts_with(&GZIP_MAGIC) {
322        return Err(CanicInfrastructureArtifactManifestError::InvalidGzip {
323            role: input.role,
324            source: std::io::Error::new(std::io::ErrorKind::InvalidData, "missing gzip header"),
325        });
326    }
327
328    let wasm_size_bytes = u64::try_from(input.wasm.len()).map_err(|_| {
329        CanicInfrastructureArtifactManifestError::ArtifactSizeOverflow {
330            role: input.role,
331            kind: "raw Wasm",
332        }
333    })?;
334    let wasm_gz_size_bytes = u64::try_from(input.wasm_gz.len()).map_err(|_| {
335        CanicInfrastructureArtifactManifestError::ArtifactSizeOverflow {
336            role: input.role,
337            kind: "gzip Wasm",
338        }
339    })?;
340    let mut decoded = Vec::new();
341    GzDecoder::new(input.wasm_gz)
342        .take(wasm_size_bytes.saturating_add(1))
343        .read_to_end(&mut decoded)
344        .map_err(
345            |source| CanicInfrastructureArtifactManifestError::InvalidGzip {
346                role: input.role,
347                source,
348            },
349        )?;
350    if decoded != input.wasm {
351        return Err(
352            CanicInfrastructureArtifactManifestError::RepresentationMismatch { role: input.role },
353        );
354    }
355
356    let entry = CanicInfrastructureArtifactEntry {
357        role: input.role,
358        package: input.package.to_string(),
359        protocol_release_identity: input.protocol_release_identity.to_string(),
360        protocol_role: input.protocol_role.clone(),
361        protocol_capabilities: input.protocol_capabilities.clone(),
362        release_build_id: input.release_build_id,
363        wasm_relative_path: input.wasm_relative_path.to_string(),
364        wasm_size_bytes,
365        wasm_sha256_hex: sha256_hex(input.wasm),
366        wasm_gz_relative_path: input.wasm_gz_relative_path.to_string(),
367        wasm_gz_size_bytes,
368        wasm_gz_sha256_hex: sha256_hex(input.wasm_gz),
369        candid_sha256: input.candid_sha256,
370        protocol_profile_digest: input.protocol_profile_digest,
371    };
372    validate_entry(release_build_id, &entry)?;
373    Ok(entry)
374}
375
376fn validate_entry(
377    release_build_id: ReleaseBuildId,
378    entry: &CanicInfrastructureArtifactEntry,
379) -> Result<(), CanicInfrastructureArtifactManifestError> {
380    if entry.release_build_id != release_build_id {
381        return Err(
382            CanicInfrastructureArtifactManifestError::ReleaseBuildMismatch {
383                role: entry.role,
384                expected: release_build_id,
385                actual: entry.release_build_id,
386            },
387        );
388    }
389    if !valid_package_name(&entry.package) {
390        return Err(CanicInfrastructureArtifactManifestError::InvalidPackage {
391            role: entry.role,
392            package: entry.package.clone(),
393        });
394    }
395    if entry.protocol_release_identity.trim().is_empty() {
396        return Err(
397            CanicInfrastructureArtifactManifestError::MissingProtocolReleaseIdentity {
398                role: entry.role,
399            },
400        );
401    }
402    if entry.protocol_role.as_str() != entry.role.protocol_role_name() {
403        return Err(
404            CanicInfrastructureArtifactManifestError::ProtocolRoleMismatch {
405                role: entry.role,
406                protocol_role: entry.protocol_role.clone(),
407            },
408        );
409    }
410    validate_path(entry.role, "raw Wasm", &entry.wasm_relative_path)?;
411    validate_path(entry.role, "gzip Wasm", &entry.wasm_gz_relative_path)?;
412    if entry.wasm_size_bytes == 0 {
413        return Err(CanicInfrastructureArtifactManifestError::ZeroSize {
414            role: entry.role,
415            kind: "raw Wasm",
416        });
417    }
418    if entry.wasm_gz_size_bytes == 0 {
419        return Err(CanicInfrastructureArtifactManifestError::ZeroSize {
420            role: entry.role,
421            kind: "gzip Wasm",
422        });
423    }
424    validate_sha256(entry.role, "raw Wasm", &entry.wasm_sha256_hex)?;
425    validate_sha256(entry.role, "gzip Wasm", &entry.wasm_gz_sha256_hex)
426}
427
428fn validate_path(
429    role: CanicInfrastructureRole,
430    kind: &'static str,
431    path: &str,
432) -> Result<(), CanicInfrastructureArtifactManifestError> {
433    if path.len() > MAX_ARTIFACT_PATH_BYTES
434        || validate_release_artifact_relative_path(path).is_err()
435    {
436        return Err(CanicInfrastructureArtifactManifestError::InvalidPath {
437            role,
438            kind,
439            path: path.to_string(),
440        });
441    }
442    Ok(())
443}
444
445fn validate_sha256(
446    role: CanicInfrastructureRole,
447    kind: &'static str,
448    value: &str,
449) -> Result<(), CanicInfrastructureArtifactManifestError> {
450    let canonical = value.len() == SHA_256_HEX_BYTES
451        && value
452            .bytes()
453            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
454        && decode_hex(value).is_ok();
455    if canonical {
456        Ok(())
457    } else {
458        Err(CanicInfrastructureArtifactManifestError::InvalidSha256 {
459            role,
460            kind,
461            value: value.to_string(),
462        })
463    }
464}