1#[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#[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 #[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#[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#[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 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 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 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 pub fn digest(&self) -> Result<[u8; 32], CanicInfrastructureArtifactManifestError> {
148 Ok(Sha256::digest(self.canonical_bytes()?).into())
149 }
150}
151
152#[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#[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}