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::ReleaseBuildId,
16};
17use flate2::read::GzDecoder;
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use thiserror::Error as ThisError;
21
22use super::{GZIP_MAGIC, WASM_MAGIC, validate_release_artifact_relative_path};
23
24pub use persistence::{
25    CanicInfrastructureArtifactBuildOutput, CanicInfrastructureArtifactPersistenceError,
26    PersistedCanicInfrastructureArtifactManifest,
27    compile_and_persist_canic_infrastructure_artifact_manifest,
28    load_persisted_canic_infrastructure_artifact_manifest,
29};
30
31const SHA_256_HEX_BYTES: usize = 64;
32const MAX_ARTIFACT_PATH_BYTES: usize = 4_096;
33const MAX_PACKAGE_BYTES: usize = 128;
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
66///
67/// CanicInfrastructureArtifactInput
68///
69/// One current-build artifact supplied to the manifest compiler.
70///
71
72#[derive(Clone, Copy, Debug)]
73pub struct CanicInfrastructureArtifactInput<'a> {
74    pub role: CanicInfrastructureRole,
75    pub package: &'a str,
76    pub release_build_id: ReleaseBuildId,
77    pub wasm_relative_path: &'a str,
78    pub wasm: &'a [u8],
79    pub wasm_gz_relative_path: &'a str,
80    pub wasm_gz: &'a [u8],
81}
82
83///
84/// CanicInfrastructureArtifactManifest
85///
86/// Canonical three-entry artifact authority for one Canic release build.
87///
88
89#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
90#[serde(deny_unknown_fields)]
91pub struct CanicInfrastructureArtifactManifest {
92    pub release_build_id: ReleaseBuildId,
93    pub entries: Vec<CanicInfrastructureArtifactEntry>,
94}
95
96impl CanicInfrastructureArtifactManifest {
97    /// Compile exact artifact evidence without accepting caller-supplied hashes.
98    pub fn compile(
99        release_build_id: ReleaseBuildId,
100        inputs: &[CanicInfrastructureArtifactInput<'_>],
101    ) -> Result<Self, CanicInfrastructureArtifactManifestError> {
102        let mut entries = inputs
103            .iter()
104            .map(|input| compile_entry(release_build_id, input))
105            .collect::<Result<Vec<_>, _>>()?;
106        entries.sort_unstable_by_key(|entry| entry.role);
107
108        let manifest = Self {
109            release_build_id,
110            entries,
111        };
112        manifest.validate()?;
113        Ok(manifest)
114    }
115
116    /// Validate the canonical role, build, package, path, size, and digest shape.
117    pub fn validate(&self) -> Result<(), CanicInfrastructureArtifactManifestError> {
118        let actual_roles = self
119            .entries
120            .iter()
121            .map(|entry| entry.role)
122            .collect::<Vec<_>>();
123        if actual_roles != REQUIRED_INFRASTRUCTURE_ROLES {
124            return Err(
125                CanicInfrastructureArtifactManifestError::InfrastructureRoleSet {
126                    actual: actual_roles,
127                },
128            );
129        }
130
131        let mut paths = BTreeSet::new();
132        for entry in &self.entries {
133            validate_entry(self.release_build_id, entry)?;
134            for path in [&entry.wasm_relative_path, &entry.wasm_gz_relative_path] {
135                if !paths.insert(path.as_str()) {
136                    return Err(
137                        CanicInfrastructureArtifactManifestError::DuplicateArtifactPath {
138                            path: path.clone(),
139                        },
140                    );
141                }
142            }
143        }
144
145        Ok(())
146    }
147
148    /// Encode the validated manifest into deterministic compact JSON bytes.
149    pub fn canonical_bytes(&self) -> Result<Vec<u8>, CanicInfrastructureArtifactManifestError> {
150        self.validate()?;
151        serde_json::to_vec(self).map_err(CanicInfrastructureArtifactManifestError::Serialization)
152    }
153
154    /// Hash the exact canonical manifest bytes.
155    pub fn digest(&self) -> Result<[u8; 32], CanicInfrastructureArtifactManifestError> {
156        Ok(Sha256::digest(self.canonical_bytes()?).into())
157    }
158}
159
160///
161/// CanicInfrastructureArtifactEntry
162///
163/// Exact package, release-build, path, size, and digest evidence for one role.
164///
165
166#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
167#[serde(deny_unknown_fields)]
168pub struct CanicInfrastructureArtifactEntry {
169    pub role: CanicInfrastructureRole,
170    pub package: String,
171    pub release_build_id: ReleaseBuildId,
172    pub wasm_relative_path: String,
173    pub wasm_size_bytes: u64,
174    pub wasm_sha256_hex: String,
175    pub wasm_gz_relative_path: String,
176    pub wasm_gz_size_bytes: u64,
177    pub wasm_gz_sha256_hex: String,
178}
179
180///
181/// CanicInfrastructureArtifactManifestError
182///
183/// Typed rejection at the infrastructure artifact authority boundary.
184///
185
186#[derive(Debug, ThisError)]
187pub enum CanicInfrastructureArtifactManifestError {
188    #[error("infrastructure artifact {role:?} {kind} size cannot be represented")]
189    ArtifactSizeOverflow {
190        role: CanicInfrastructureRole,
191        kind: &'static str,
192    },
193
194    #[error("infrastructure artifact path is duplicated: {path}")]
195    DuplicateArtifactPath { path: String },
196
197    #[error("infrastructure artifact {role:?} has an empty {kind} payload")]
198    EmptyArtifact {
199        role: CanicInfrastructureRole,
200        kind: &'static str,
201    },
202
203    #[error("infrastructure artifact {role:?} has invalid gzip Wasm: {source}")]
204    InvalidGzip {
205        role: CanicInfrastructureRole,
206        #[source]
207        source: std::io::Error,
208    },
209
210    #[error(
211        "infrastructure manifest must contain each exact role once in canonical order: {actual:?}"
212    )]
213    InfrastructureRoleSet {
214        actual: Vec<CanicInfrastructureRole>,
215    },
216
217    #[error("infrastructure artifact {role:?} has invalid package identity: {package}")]
218    InvalidPackage {
219        role: CanicInfrastructureRole,
220        package: String,
221    },
222
223    #[error("infrastructure artifact {role:?} has invalid {kind} path: {path}")]
224    InvalidPath {
225        role: CanicInfrastructureRole,
226        kind: &'static str,
227        path: String,
228    },
229
230    #[error("infrastructure artifact {role:?} has invalid {kind} SHA-256: {value}")]
231    InvalidSha256 {
232        role: CanicInfrastructureRole,
233        kind: &'static str,
234        value: String,
235    },
236
237    #[error("infrastructure artifact {role:?} has invalid raw Wasm bytes")]
238    InvalidWasm { role: CanicInfrastructureRole },
239
240    #[error(
241        "infrastructure artifact {role:?} release build {actual} does not match manifest release build {expected}"
242    )]
243    ReleaseBuildMismatch {
244        role: CanicInfrastructureRole,
245        expected: ReleaseBuildId,
246        actual: ReleaseBuildId,
247    },
248
249    #[error("infrastructure artifact {role:?} raw and gzip Wasm representations differ")]
250    RepresentationMismatch { role: CanicInfrastructureRole },
251
252    #[error("failed to serialize infrastructure artifact manifest: {0}")]
253    Serialization(serde_json::Error),
254
255    #[error("infrastructure artifact {role:?} {kind} size must be nonzero")]
256    ZeroSize {
257        role: CanicInfrastructureRole,
258        kind: &'static str,
259    },
260}
261
262fn compile_entry(
263    release_build_id: ReleaseBuildId,
264    input: &CanicInfrastructureArtifactInput<'_>,
265) -> Result<CanicInfrastructureArtifactEntry, CanicInfrastructureArtifactManifestError> {
266    if input.release_build_id != release_build_id {
267        return Err(
268            CanicInfrastructureArtifactManifestError::ReleaseBuildMismatch {
269                role: input.role,
270                expected: release_build_id,
271                actual: input.release_build_id,
272            },
273        );
274    }
275    if input.wasm.is_empty() {
276        return Err(CanicInfrastructureArtifactManifestError::EmptyArtifact {
277            role: input.role,
278            kind: "raw Wasm",
279        });
280    }
281    if !input.wasm.starts_with(&WASM_MAGIC) {
282        return Err(CanicInfrastructureArtifactManifestError::InvalidWasm { role: input.role });
283    }
284    if input.wasm_gz.is_empty() {
285        return Err(CanicInfrastructureArtifactManifestError::EmptyArtifact {
286            role: input.role,
287            kind: "gzip Wasm",
288        });
289    }
290    if !input.wasm_gz.starts_with(&GZIP_MAGIC) {
291        return Err(CanicInfrastructureArtifactManifestError::InvalidGzip {
292            role: input.role,
293            source: std::io::Error::new(std::io::ErrorKind::InvalidData, "missing gzip header"),
294        });
295    }
296
297    let wasm_size_bytes = u64::try_from(input.wasm.len()).map_err(|_| {
298        CanicInfrastructureArtifactManifestError::ArtifactSizeOverflow {
299            role: input.role,
300            kind: "raw Wasm",
301        }
302    })?;
303    let wasm_gz_size_bytes = u64::try_from(input.wasm_gz.len()).map_err(|_| {
304        CanicInfrastructureArtifactManifestError::ArtifactSizeOverflow {
305            role: input.role,
306            kind: "gzip Wasm",
307        }
308    })?;
309    let mut decoded = Vec::new();
310    GzDecoder::new(input.wasm_gz)
311        .take(wasm_size_bytes.saturating_add(1))
312        .read_to_end(&mut decoded)
313        .map_err(
314            |source| CanicInfrastructureArtifactManifestError::InvalidGzip {
315                role: input.role,
316                source,
317            },
318        )?;
319    if decoded != input.wasm {
320        return Err(
321            CanicInfrastructureArtifactManifestError::RepresentationMismatch { role: input.role },
322        );
323    }
324
325    let entry = CanicInfrastructureArtifactEntry {
326        role: input.role,
327        package: input.package.to_string(),
328        release_build_id: input.release_build_id,
329        wasm_relative_path: input.wasm_relative_path.to_string(),
330        wasm_size_bytes,
331        wasm_sha256_hex: sha256_hex(input.wasm),
332        wasm_gz_relative_path: input.wasm_gz_relative_path.to_string(),
333        wasm_gz_size_bytes,
334        wasm_gz_sha256_hex: sha256_hex(input.wasm_gz),
335    };
336    validate_entry(release_build_id, &entry)?;
337    Ok(entry)
338}
339
340fn validate_entry(
341    release_build_id: ReleaseBuildId,
342    entry: &CanicInfrastructureArtifactEntry,
343) -> Result<(), CanicInfrastructureArtifactManifestError> {
344    if entry.release_build_id != release_build_id {
345        return Err(
346            CanicInfrastructureArtifactManifestError::ReleaseBuildMismatch {
347                role: entry.role,
348                expected: release_build_id,
349                actual: entry.release_build_id,
350            },
351        );
352    }
353    if !valid_package_name(&entry.package) {
354        return Err(CanicInfrastructureArtifactManifestError::InvalidPackage {
355            role: entry.role,
356            package: entry.package.clone(),
357        });
358    }
359    validate_path(entry.role, "raw Wasm", &entry.wasm_relative_path)?;
360    validate_path(entry.role, "gzip Wasm", &entry.wasm_gz_relative_path)?;
361    if entry.wasm_size_bytes == 0 {
362        return Err(CanicInfrastructureArtifactManifestError::ZeroSize {
363            role: entry.role,
364            kind: "raw Wasm",
365        });
366    }
367    if entry.wasm_gz_size_bytes == 0 {
368        return Err(CanicInfrastructureArtifactManifestError::ZeroSize {
369            role: entry.role,
370            kind: "gzip Wasm",
371        });
372    }
373    validate_sha256(entry.role, "raw Wasm", &entry.wasm_sha256_hex)?;
374    validate_sha256(entry.role, "gzip Wasm", &entry.wasm_gz_sha256_hex)
375}
376
377fn validate_path(
378    role: CanicInfrastructureRole,
379    kind: &'static str,
380    path: &str,
381) -> Result<(), CanicInfrastructureArtifactManifestError> {
382    if path.len() > MAX_ARTIFACT_PATH_BYTES
383        || validate_release_artifact_relative_path(path).is_err()
384    {
385        return Err(CanicInfrastructureArtifactManifestError::InvalidPath {
386            role,
387            kind,
388            path: path.to_string(),
389        });
390    }
391    Ok(())
392}
393
394fn validate_sha256(
395    role: CanicInfrastructureRole,
396    kind: &'static str,
397    value: &str,
398) -> Result<(), CanicInfrastructureArtifactManifestError> {
399    let canonical = value.len() == SHA_256_HEX_BYTES
400        && value
401            .bytes()
402            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
403        && decode_hex(value).is_ok();
404    if canonical {
405        Ok(())
406    } else {
407        Err(CanicInfrastructureArtifactManifestError::InvalidSha256 {
408            role,
409            kind,
410            value: value.to_string(),
411        })
412    }
413}
414
415fn valid_package_name(package: &str) -> bool {
416    let bytes = package.as_bytes();
417    !bytes.is_empty()
418        && bytes.len() <= MAX_PACKAGE_BYTES
419        && bytes.first().is_some_and(u8::is_ascii_alphanumeric)
420        && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
421        && bytes
422            .iter()
423            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
424}