1mod 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#[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 #[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#[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#[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 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 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 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 pub fn digest(&self) -> Result<[u8; 32], CanicInfrastructureArtifactManifestError> {
155 Ok(Sha256::digest(self.canonical_bytes()?).into())
156 }
157}
158
159#[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#[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}