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