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