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